mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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,6 +1,6 @@
|
|||||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||||
import { pick } from 'lodash';
|
import { pick } from 'lodash';
|
||||||
import { Node, NodeConnectionType, commonCORSParameters } from 'n8n-workflow';
|
import { Node, NodeConnectionType } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
IDataObject,
|
IDataObject,
|
||||||
IWebhookFunctions,
|
IWebhookFunctions,
|
||||||
@@ -241,14 +241,19 @@ export class ChatTrigger extends Node {
|
|||||||
default: {},
|
default: {},
|
||||||
options: [
|
options: [
|
||||||
// CORS parameters are only valid for when chat is used in hosted or webhook mode
|
// CORS parameters are only valid for when chat is used in hosted or webhook mode
|
||||||
...commonCORSParameters.map((p) => ({
|
{
|
||||||
...p,
|
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.',
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
show: {
|
show: {
|
||||||
'/mode': ['hostedChat', 'webhook'],
|
'/mode': ['hostedChat', 'webhook'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})),
|
},
|
||||||
{
|
{
|
||||||
...allowFileUploadsOption,
|
...allowFileUploadsOption,
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
|
|||||||
125
packages/cli/src/__tests__/active-workflow-manager.test.ts
Normal file
125
packages/cli/src/__tests__/active-workflow-manager.test.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { InstanceSettings } from 'n8n-core';
|
||||||
|
import type {
|
||||||
|
WorkflowParameters,
|
||||||
|
INode,
|
||||||
|
INodeType,
|
||||||
|
INodeTypeDescription,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||||
|
import type { NodeTypes } from '@/node-types';
|
||||||
|
|
||||||
|
describe('ActiveWorkflowManager', () => {
|
||||||
|
let activeWorkflowManager: ActiveWorkflowManager;
|
||||||
|
const instanceSettings = mock<InstanceSettings>();
|
||||||
|
const nodeTypes = mock<NodeTypes>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
activeWorkflowManager = new ActiveWorkflowManager(
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
nodeTypes,
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
instanceSettings,
|
||||||
|
mock(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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' });
|
||||||
|
|
||||||
|
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 workflow = new Workflow(mock<WorkflowParameters>({ nodeTypes, nodes: [node] }));
|
||||||
|
const canBeActivated = activeWorkflowManager.checkIfWorkflowCanBeActivated(
|
||||||
|
workflow,
|
||||||
|
ignoredNodes,
|
||||||
|
);
|
||||||
|
expect(canBeActivated).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldAddWebhooks', () => {
|
||||||
|
describe('if leader', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.assign(instanceSettings, { isLeader: true, isFollower: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return `true` for `init`', () => {
|
||||||
|
// ensure webhooks are populated on init: https://github.com/n8n-io/n8n/pull/8830
|
||||||
|
const result = activeWorkflowManager.shouldAddWebhooks('init');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return `false` for `leadershipChange`', () => {
|
||||||
|
const result = activeWorkflowManager.shouldAddWebhooks('leadershipChange');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return `true` for `update` or `activate`', () => {
|
||||||
|
const modes = ['update', 'activate'] as WorkflowActivateMode[];
|
||||||
|
for (const mode of modes) {
|
||||||
|
const result = activeWorkflowManager.shouldAddWebhooks(mode);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('if follower', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.assign(instanceSettings, { isLeader: false, isFollower: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return `false` for `update` or `activate`', () => {
|
||||||
|
const modes = ['update', 'activate'] as WorkflowActivateMode[];
|
||||||
|
for (const mode of modes) {
|
||||||
|
const result = activeWorkflowManager.shouldAddWebhooks(mode);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type { DirectoryLoader } from 'n8n-core';
|
import type { DirectoryLoader } from 'n8n-core';
|
||||||
|
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
|
||||||
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
import { LoadNodesAndCredentials } from '../load-nodes-and-credentials';
|
import { LoadNodesAndCredentials } from '../load-nodes-and-credentials';
|
||||||
|
|
||||||
@@ -34,4 +36,179 @@ describe('LoadNodesAndCredentials', () => {
|
|||||||
expect(result).toBeUndefined();
|
expect(result).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('convertNodeToAiTool', () => {
|
||||||
|
const instance = new LoadNodesAndCredentials(mock(), mock(), mock(), mock());
|
||||||
|
|
||||||
|
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 = instance.convertNodeToAiTool(fullNodeWrapper);
|
||||||
|
expect(result.description.name).toBe('testNodeTool');
|
||||||
|
expect(result.description.displayName).toBe('Test Node Tool');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update inputs and outputs', () => {
|
||||||
|
const result = instance.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 = instance.convertNodeToAiTool(fullNodeWrapper);
|
||||||
|
expect(result.description.usableAsTool).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add toolDescription property if it doesn't exist", () => {
|
||||||
|
const result = instance.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 = instance.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 = instance.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 = instance.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 = instance.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 = instance.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 = instance.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 = instance.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 = instance.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 = instance.convertNodeToAiTool(fullNodeWrapper);
|
||||||
|
expect(result.description.name).toBe('special@#$%NodeTool');
|
||||||
|
expect(result.description.displayName).toBe('Special @#$% Node Tool');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
|
|
||||||
describe('NodeTypes', () => {
|
describe('NodeTypes', () => {
|
||||||
@@ -104,6 +104,9 @@ describe('NodeTypes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the tool node-type when requested as tool', () => {
|
it('should return the tool node-type when requested as tool', () => {
|
||||||
|
// @ts-expect-error don't mock convertNodeToAiTool for now
|
||||||
|
loadNodesAndCredentials.convertNodeToAiTool =
|
||||||
|
LoadNodesAndCredentials.prototype.convertNodeToAiTool;
|
||||||
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool');
|
const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool');
|
||||||
expect(result).not.toEqual(toolSupportingNode);
|
expect(result).not.toEqual(toolSupportingNode);
|
||||||
expect(result.description.name).toEqual('n8n-nodes-base.testNodeTool');
|
expect(result.description.name).toEqual('n8n-nodes-base.testNodeTool');
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ActiveWorkflows,
|
ActiveWorkflows,
|
||||||
ErrorReporter,
|
ErrorReporter,
|
||||||
InstanceSettings,
|
InstanceSettings,
|
||||||
NodeExecuteFunctions,
|
|
||||||
PollContext,
|
PollContext,
|
||||||
TriggerContext,
|
TriggerContext,
|
||||||
} from 'n8n-core';
|
} from 'n8n-core';
|
||||||
@@ -186,12 +184,7 @@ export class ActiveWorkflowManager {
|
|||||||
try {
|
try {
|
||||||
// TODO: this should happen in a transaction, that way we don't need to manually remove this in `catch`
|
// TODO: this should happen in a transaction, that way we don't need to manually remove this in `catch`
|
||||||
await this.webhookService.storeWebhook(webhook);
|
await this.webhookService.storeWebhook(webhook);
|
||||||
await workflow.createWebhookIfNotExists(
|
await this.webhookService.createWebhookIfNotExists(workflow, webhookData, mode, activation);
|
||||||
webhookData,
|
|
||||||
NodeExecuteFunctions,
|
|
||||||
mode,
|
|
||||||
activation,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (activation === 'init' && error.name === 'QueryFailedError') {
|
if (activation === 'init' && error.name === 'QueryFailedError') {
|
||||||
// n8n does not remove the registered webhooks on exit.
|
// n8n does not remove the registered webhooks on exit.
|
||||||
@@ -261,7 +254,7 @@ export class ActiveWorkflowManager {
|
|||||||
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true);
|
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true);
|
||||||
|
|
||||||
for (const webhookData of webhooks) {
|
for (const webhookData of webhooks) {
|
||||||
await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update');
|
await this.webhookService.deleteWebhook(workflow, webhookData, mode, 'update');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.workflowStaticDataService.saveStaticData(workflow);
|
await this.workflowStaticDataService.saveStaticData(workflow);
|
||||||
@@ -557,7 +550,7 @@ export class ActiveWorkflowManager {
|
|||||||
settings: dbWorkflow.settings,
|
settings: dbWorkflow.settings,
|
||||||
});
|
});
|
||||||
|
|
||||||
const canBeActivated = workflow.checkIfWorkflowCanBeActivated(STARTING_NODES);
|
const canBeActivated = this.checkIfWorkflowCanBeActivated(workflow, STARTING_NODES);
|
||||||
|
|
||||||
if (!canBeActivated) {
|
if (!canBeActivated) {
|
||||||
throw new WorkflowActivationError(
|
throw new WorkflowActivationError(
|
||||||
@@ -601,6 +594,48 @@ export class ActiveWorkflowManager {
|
|||||||
return shouldDisplayActivationMessage;
|
return shouldDisplayActivationMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(workflow: Workflow, ignoreNodeTypes?: string[]): boolean {
|
||||||
|
let node: INode;
|
||||||
|
let nodeType: INodeType | undefined;
|
||||||
|
|
||||||
|
for (const nodeName of Object.keys(workflow.nodes)) {
|
||||||
|
node = workflow.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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count all triggers in the workflow, excluding Manual Trigger.
|
* Count all triggers in the workflow, excluding Manual Trigger.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import type {
|
|||||||
ICredentialType,
|
ICredentialType,
|
||||||
INodeType,
|
INodeType,
|
||||||
IVersionedNodeType,
|
IVersionedNodeType,
|
||||||
|
INodeProperties,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeHelpers, ApplicationError } from 'n8n-workflow';
|
import { ApplicationError, NodeConnectionType } from 'n8n-workflow';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import picocolors from 'picocolors';
|
import picocolors from 'picocolors';
|
||||||
import { Container, Service } from 'typedi';
|
import { Container, Service } from 'typedi';
|
||||||
@@ -293,7 +294,7 @@ export class LoadNodesAndCredentials {
|
|||||||
for (const usableNode of usableNodes) {
|
for (const usableNode of usableNodes) {
|
||||||
const description: INodeTypeBaseDescription | INodeTypeDescription =
|
const description: INodeTypeBaseDescription | INodeTypeDescription =
|
||||||
structuredClone(usableNode);
|
structuredClone(usableNode);
|
||||||
const wrapped = NodeHelpers.convertNodeToAiTool({ description }).description;
|
const wrapped = this.convertNodeToAiTool({ description }).description;
|
||||||
|
|
||||||
this.types.nodes.push(wrapped);
|
this.types.nodes.push(wrapped);
|
||||||
this.known.nodes[wrapped.name] = structuredClone(this.known.nodes[usableNode.name]);
|
this.known.nodes[wrapped.name] = structuredClone(this.known.nodes[usableNode.name]);
|
||||||
@@ -396,6 +397,101 @@ export class LoadNodesAndCredentials {
|
|||||||
throw new UnrecognizedCredentialTypeError(credentialType);
|
throw new UnrecognizedCredentialTypeError(credentialType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
async setupHotReload() {
|
async setupHotReload() {
|
||||||
const { default: debounce } = await import('lodash/debounce');
|
const { default: debounce } = await import('lodash/debounce');
|
||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class NodeTypes implements INodeTypes {
|
|||||||
const clonedNode = Object.create(versionedNodeType, {
|
const clonedNode = Object.create(versionedNodeType, {
|
||||||
description: { value: clonedDescription },
|
description: { value: clonedDescription },
|
||||||
}) as INodeType;
|
}) as INodeType;
|
||||||
const tool = NodeHelpers.convertNodeToAiTool(clonedNode);
|
const tool = this.loadNodesAndCredentials.convertNodeToAiTool(clonedNode);
|
||||||
loadedNodes[nodeType + 'Tool'] = { sourcePath: '', type: tool };
|
loadedNodes[nodeType + 'Tool'] = { sourcePath: '', type: tool };
|
||||||
return tool;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import { ErrorReporter, NodeExecuteFunctions } from 'n8n-core';
|
import { ErrorReporter, NodeExecuteFunctions, RoutingNode } from 'n8n-core';
|
||||||
import type {
|
import type {
|
||||||
ICredentialsDecrypted,
|
ICredentialsDecrypted,
|
||||||
ICredentialTestFunction,
|
ICredentialTestFunction,
|
||||||
@@ -23,13 +23,7 @@ import type {
|
|||||||
ICredentialTestFunctions,
|
ICredentialTestFunctions,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import { VersionedNodeType, NodeHelpers, Workflow, ApplicationError } from 'n8n-workflow';
|
||||||
VersionedNodeType,
|
|
||||||
NodeHelpers,
|
|
||||||
RoutingNode,
|
|
||||||
Workflow,
|
|
||||||
ApplicationError,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import { CredentialTypes } from '@/credential-types';
|
import { CredentialTypes } from '@/credential-types';
|
||||||
@@ -312,7 +306,6 @@ export class CredentialsTester {
|
|||||||
runIndex,
|
runIndex,
|
||||||
nodeTypeCopy,
|
nodeTypeCopy,
|
||||||
{ node, data: {}, source: null },
|
{ node, data: {}, source: null },
|
||||||
NodeExecuteFunctions,
|
|
||||||
credentialsDecrypted,
|
credentialsDecrypted,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LoadOptionsContext, NodeExecuteFunctions } from 'n8n-core';
|
import { LoadOptionsContext, RoutingNode } from 'n8n-core';
|
||||||
import type {
|
import type {
|
||||||
ILoadOptions,
|
ILoadOptions,
|
||||||
ILoadOptionsFunctions,
|
ILoadOptionsFunctions,
|
||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
NodeParameterValueType,
|
NodeParameterValueType,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { Workflow, RoutingNode, ApplicationError } from 'n8n-workflow';
|
import { Workflow, ApplicationError } from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
@@ -105,13 +105,11 @@ export class DynamicNodeParametersService {
|
|||||||
main: [[{ json: {} }]],
|
main: [[{ json: {} }]],
|
||||||
};
|
};
|
||||||
|
|
||||||
const optionsData = await routingNode.runNode(
|
const optionsData = await routingNode.runNode(inputData, runIndex, tempNode, {
|
||||||
inputData,
|
node,
|
||||||
runIndex,
|
source: null,
|
||||||
tempNode,
|
data: {},
|
||||||
{ node, source: null, data: {} },
|
});
|
||||||
NodeExecuteFunctions,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (optionsData?.length === 0) {
|
if (optionsData?.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
} from '@/webhooks/test-webhook-registrations.service';
|
} from '@/webhooks/test-webhook-registrations.service';
|
||||||
import { TestWebhooks } from '@/webhooks/test-webhooks';
|
import { TestWebhooks } from '@/webhooks/test-webhooks';
|
||||||
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
||||||
|
import type { WebhookService } from '@/webhooks/webhook.service';
|
||||||
import type { WebhookRequest } from '@/webhooks/webhook.types';
|
import type { WebhookRequest } from '@/webhooks/webhook.types';
|
||||||
import * as AdditionalData from '@/workflow-execute-additional-data';
|
import * as AdditionalData from '@/workflow-execute-additional-data';
|
||||||
|
|
||||||
@@ -38,13 +39,20 @@ const webhook = mock<IWebhookData>({
|
|||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const registrations = mock<TestWebhookRegistrationsService>();
|
|
||||||
|
|
||||||
let testWebhooks: TestWebhooks;
|
|
||||||
|
|
||||||
describe('TestWebhooks', () => {
|
describe('TestWebhooks', () => {
|
||||||
|
const registrations = mock<TestWebhookRegistrationsService>();
|
||||||
|
const webhookService = mock<WebhookService>();
|
||||||
|
|
||||||
|
const testWebhooks = new TestWebhooks(
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
registrations,
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
webhookService,
|
||||||
|
);
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
testWebhooks = new TestWebhooks(mock(), mock(), registrations, mock(), mock());
|
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +76,7 @@ describe('TestWebhooks', () => {
|
|||||||
const needsWebhook = await testWebhooks.needsWebhook(args);
|
const needsWebhook = await testWebhooks.needsWebhook(args);
|
||||||
|
|
||||||
const [registerOrder] = registrations.register.mock.invocationCallOrder;
|
const [registerOrder] = registrations.register.mock.invocationCallOrder;
|
||||||
const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder;
|
const [createOrder] = webhookService.createWebhookIfNotExists.mock.invocationCallOrder;
|
||||||
|
|
||||||
expect(registerOrder).toBeLessThan(createOrder);
|
expect(registerOrder).toBeLessThan(createOrder);
|
||||||
expect(needsWebhook).toBe(true);
|
expect(needsWebhook).toBe(true);
|
||||||
@@ -132,11 +140,11 @@ describe('TestWebhooks', () => {
|
|||||||
|
|
||||||
// ASSERT
|
// ASSERT
|
||||||
const [registerOrder] = registrations.register.mock.invocationCallOrder;
|
const [registerOrder] = registrations.register.mock.invocationCallOrder;
|
||||||
const [createOrder] = workflow.createWebhookIfNotExists.mock.invocationCallOrder;
|
const [createOrder] = webhookService.createWebhookIfNotExists.mock.invocationCallOrder;
|
||||||
|
|
||||||
expect(registerOrder).toBeLessThan(createOrder);
|
expect(registerOrder).toBeLessThan(createOrder);
|
||||||
expect(registrations.register.mock.calls[0][0].webhook.node).toBe(webhook2.node);
|
expect(registrations.register.mock.calls[0][0].webhook.node).toBe(webhook2.node);
|
||||||
expect(workflow.createWebhookIfNotExists.mock.calls[0][0].node).toBe(webhook2.node);
|
expect(webhookService.createWebhookIfNotExists.mock.calls[0][1].node).toBe(webhook2.node);
|
||||||
expect(needsWebhook).toBe(true);
|
expect(needsWebhook).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { WaitingForms } from '@/webhooks/waiting-forms';
|
|||||||
|
|
||||||
describe('WaitingForms', () => {
|
describe('WaitingForms', () => {
|
||||||
const executionRepository = mock<ExecutionRepository>();
|
const executionRepository = mock<ExecutionRepository>();
|
||||||
const waitingWebhooks = new WaitingForms(mock(), mock(), executionRepository);
|
const waitingWebhooks = new WaitingForms(mock(), mock(), executionRepository, mock());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { WaitingWebhookRequest } from '@/webhooks/webhook.types';
|
|||||||
|
|
||||||
describe('WaitingWebhooks', () => {
|
describe('WaitingWebhooks', () => {
|
||||||
const executionRepository = mock<ExecutionRepository>();
|
const executionRepository = mock<ExecutionRepository>();
|
||||||
const waitingWebhooks = new WaitingWebhooks(mock(), mock(), executionRepository);
|
const waitingWebhooks = new WaitingWebhooks(mock(), mock(), executionRepository, mock());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { INode, INodeType, IWebhookData, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||||
|
import { Workflow } from 'n8n-workflow';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { WebhookEntity } from '@/databases/entities/webhook-entity';
|
import { WebhookEntity } from '@/databases/entities/webhook-entity';
|
||||||
import { WebhookRepository } from '@/databases/repositories/webhook.repository';
|
import type { WebhookRepository } from '@/databases/repositories/webhook.repository';
|
||||||
import { CacheService } from '@/services/cache/cache.service';
|
import type { NodeTypes } from '@/node-types';
|
||||||
|
import type { CacheService } from '@/services/cache/cache.service';
|
||||||
import { WebhookService } from '@/webhooks/webhook.service';
|
import { WebhookService } from '@/webhooks/webhook.service';
|
||||||
import { mockInstance } from '@test/mocking';
|
|
||||||
|
|
||||||
const createWebhook = (method: string, path: string, webhookId?: string, pathSegments?: number) =>
|
const createWebhook = (method: string, path: string, webhookId?: string, pathSegments?: number) =>
|
||||||
Object.assign(new WebhookEntity(), {
|
Object.assign(new WebhookEntity(), {
|
||||||
@@ -16,9 +19,11 @@ const createWebhook = (method: string, path: string, webhookId?: string, pathSeg
|
|||||||
}) as WebhookEntity;
|
}) as WebhookEntity;
|
||||||
|
|
||||||
describe('WebhookService', () => {
|
describe('WebhookService', () => {
|
||||||
const webhookRepository = mockInstance(WebhookRepository);
|
const webhookRepository = mock<WebhookRepository>();
|
||||||
const cacheService = mockInstance(CacheService);
|
const cacheService = mock<CacheService>();
|
||||||
const webhookService = new WebhookService(webhookRepository, cacheService);
|
const nodeTypes = mock<NodeTypes>();
|
||||||
|
const webhookService = new WebhookService(mock(), webhookRepository, cacheService, nodeTypes);
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
config.load(config.default);
|
config.load(config.default);
|
||||||
@@ -188,4 +193,171 @@ describe('WebhookService', () => {
|
|||||||
expect(webhookRepository.upsert).toHaveBeenCalledWith(mockWebhook, ['method', 'webhookPath']);
|
expect(webhookRepository.upsert).toHaveBeenCalledWith(mockWebhook, ['method', 'webhookPath']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getNodeWebhooks()', () => {
|
||||||
|
const workflow = new Workflow({
|
||||||
|
id: 'test-workflow',
|
||||||
|
nodes: [],
|
||||||
|
connections: {},
|
||||||
|
active: true,
|
||||||
|
nodeTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return empty array if node is disabled', async () => {
|
||||||
|
const node = { disabled: true } as INode;
|
||||||
|
|
||||||
|
const webhooks = webhookService.getNodeWebhooks(workflow, node, additionalData);
|
||||||
|
|
||||||
|
expect(webhooks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return webhooks for node with webhook definitions', async () => {
|
||||||
|
const node = {
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
disabled: false,
|
||||||
|
} as INode;
|
||||||
|
|
||||||
|
const nodeType = {
|
||||||
|
description: {
|
||||||
|
webhooks: [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
httpMethod: 'GET',
|
||||||
|
path: '/webhook',
|
||||||
|
isFullPath: false,
|
||||||
|
restartWebhook: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as INodeType;
|
||||||
|
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
|
||||||
|
const webhooks = webhookService.getNodeWebhooks(workflow, node, additionalData);
|
||||||
|
|
||||||
|
expect(webhooks).toHaveLength(1);
|
||||||
|
expect(webhooks[0]).toMatchObject({
|
||||||
|
httpMethod: 'GET',
|
||||||
|
node: 'Webhook',
|
||||||
|
workflowId: 'test-workflow',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createWebhookIfNotExists()', () => {
|
||||||
|
const workflow = new Workflow({
|
||||||
|
id: 'test-workflow',
|
||||||
|
nodes: [
|
||||||
|
mock<INode>({
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
typeVersion: 1,
|
||||||
|
parameters: {},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
connections: {},
|
||||||
|
active: false,
|
||||||
|
nodeTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const webhookData = mock<IWebhookData>({
|
||||||
|
node: 'Webhook',
|
||||||
|
webhookDescription: {
|
||||||
|
name: 'default',
|
||||||
|
httpMethod: 'GET',
|
||||||
|
path: '/webhook',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultWebhookMethods = {
|
||||||
|
checkExists: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
webhookMethods: { default: defaultWebhookMethods },
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create webhook if it does not exist', async () => {
|
||||||
|
defaultWebhookMethods.checkExists.mockResolvedValue(false);
|
||||||
|
defaultWebhookMethods.create.mockResolvedValue(true);
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
|
||||||
|
await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init');
|
||||||
|
|
||||||
|
expect(defaultWebhookMethods.checkExists).toHaveBeenCalled();
|
||||||
|
expect(defaultWebhookMethods.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not create webhook if it already exists', async () => {
|
||||||
|
defaultWebhookMethods.checkExists.mockResolvedValue(true);
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
|
||||||
|
await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init');
|
||||||
|
|
||||||
|
expect(defaultWebhookMethods.checkExists).toHaveBeenCalled();
|
||||||
|
expect(defaultWebhookMethods.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle case when webhook methods are not defined', async () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue({} as INodeType);
|
||||||
|
|
||||||
|
await webhookService.createWebhookIfNotExists(workflow, webhookData, 'trigger', 'init');
|
||||||
|
// Test passes if no error is thrown when webhook methods are undefined
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteWebhook()', () => {
|
||||||
|
test('should call runWebhookMethod with delete', async () => {
|
||||||
|
const workflow = mock<Workflow>();
|
||||||
|
const webhookData = mock<IWebhookData>();
|
||||||
|
const runWebhookMethodSpy = jest.spyOn(webhookService as any, 'runWebhookMethod');
|
||||||
|
|
||||||
|
await webhookService.deleteWebhook(workflow, webhookData, 'trigger', 'init');
|
||||||
|
|
||||||
|
expect(runWebhookMethodSpy).toHaveBeenCalledWith(
|
||||||
|
'delete',
|
||||||
|
workflow,
|
||||||
|
webhookData,
|
||||||
|
'trigger',
|
||||||
|
'init',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runWebhook()', () => {
|
||||||
|
const workflow = mock<Workflow>();
|
||||||
|
const webhookData = mock<IWebhookData>();
|
||||||
|
const node = mock<INode>();
|
||||||
|
const responseData = { workflowData: [] };
|
||||||
|
|
||||||
|
test('should throw error if node does not have webhooks', async () => {
|
||||||
|
const nodeType = {} as INodeType;
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
webhookService.runWebhook(workflow, webhookData, node, additionalData, 'trigger', null),
|
||||||
|
).rejects.toThrow('Node does not have any webhooks defined');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should execute webhook and return response data', async () => {
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
webhook: jest.fn().mockResolvedValue(responseData),
|
||||||
|
});
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
|
||||||
|
const result = await webhookService.runWebhook(
|
||||||
|
workflow,
|
||||||
|
webhookData,
|
||||||
|
node,
|
||||||
|
additionalData,
|
||||||
|
'trigger',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(responseData);
|
||||||
|
expect(nodeType.webhook).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { Workflow, NodeHelpers, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
import { Workflow, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||||
import type { INode, IWebhookData, IHttpRequestMethods } from 'n8n-workflow';
|
import type { INode, IWebhookData, IHttpRequestMethods } from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
@@ -114,11 +114,9 @@ export class LiveWebhooks implements IWebhookManager {
|
|||||||
|
|
||||||
const additionalData = await WorkflowExecuteAdditionalData.getBase();
|
const additionalData = await WorkflowExecuteAdditionalData.getBase();
|
||||||
|
|
||||||
const webhookData = NodeHelpers.getNodeWebhooks(
|
const webhookData = this.webhookService
|
||||||
workflow,
|
.getNodeWebhooks(workflow, workflow.getNode(webhook.node) as INode, additionalData)
|
||||||
workflow.getNode(webhook.node) as INode,
|
.find((w) => w.httpMethod === httpMethod && w.path === webhook.webhookPath) as IWebhookData;
|
||||||
additionalData,
|
|
||||||
).find((w) => w.httpMethod === httpMethod && w.path === webhook.webhookPath) as IWebhookData;
|
|
||||||
|
|
||||||
// Get the node which has the webhook defined to know where to start from and to
|
// Get the node which has the webhook defined to know where to start from and to
|
||||||
// get additional data
|
// get additional data
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
import * as NodeExecuteFunctions from 'n8n-core';
|
|
||||||
import { InstanceSettings } from 'n8n-core';
|
import { InstanceSettings } from 'n8n-core';
|
||||||
import { WebhookPathTakenError, Workflow } from 'n8n-workflow';
|
import { WebhookPathTakenError, Workflow } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
@@ -25,6 +24,7 @@ import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
|||||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||||
import type { WorkflowRequest } from '@/workflows/workflow.request';
|
import type { WorkflowRequest } from '@/workflows/workflow.request';
|
||||||
|
|
||||||
|
import { WebhookService } from './webhook.service';
|
||||||
import type {
|
import type {
|
||||||
IWebhookResponseCallbackData,
|
IWebhookResponseCallbackData,
|
||||||
IWebhookManager,
|
IWebhookManager,
|
||||||
@@ -44,6 +44,7 @@ export class TestWebhooks implements IWebhookManager {
|
|||||||
private readonly registrations: TestWebhookRegistrationsService,
|
private readonly registrations: TestWebhookRegistrationsService,
|
||||||
private readonly instanceSettings: InstanceSettings,
|
private readonly instanceSettings: InstanceSettings,
|
||||||
private readonly publisher: Publisher,
|
private readonly publisher: Publisher,
|
||||||
|
private readonly webhookService: WebhookService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private timeouts: { [webhookKey: string]: NodeJS.Timeout } = {};
|
private timeouts: { [webhookKey: string]: NodeJS.Timeout } = {};
|
||||||
@@ -314,7 +315,7 @@ export class TestWebhooks implements IWebhookManager {
|
|||||||
*/
|
*/
|
||||||
await this.registrations.register(registration);
|
await this.registrations.register(registration);
|
||||||
|
|
||||||
await workflow.createWebhookIfNotExists(webhook, NodeExecuteFunctions, 'manual', 'manual');
|
await this.webhookService.createWebhookIfNotExists(workflow, webhook, 'manual', 'manual');
|
||||||
|
|
||||||
cacheableWebhook.staticData = workflow.staticData;
|
cacheableWebhook.staticData = workflow.staticData;
|
||||||
|
|
||||||
@@ -431,7 +432,7 @@ export class TestWebhooks implements IWebhookManager {
|
|||||||
|
|
||||||
if (staticData) workflow.staticData = staticData;
|
if (staticData) workflow.staticData = staticData;
|
||||||
|
|
||||||
await workflow.deleteWebhook(webhook, NodeExecuteFunctions, 'internal', 'update');
|
await this.webhookService.deleteWebhook(workflow, webhook, 'internal', 'update');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.registrations.deregisterAll();
|
await this.registrations.deregisterAll();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
FORM_NODE_TYPE,
|
FORM_NODE_TYPE,
|
||||||
type INodes,
|
type INodes,
|
||||||
type IWorkflowBase,
|
type IWorkflowBase,
|
||||||
NodeHelpers,
|
|
||||||
SEND_AND_WAIT_OPERATION,
|
SEND_AND_WAIT_OPERATION,
|
||||||
WAIT_NODE_TYPE,
|
WAIT_NODE_TYPE,
|
||||||
Workflow,
|
Workflow,
|
||||||
@@ -19,6 +18,7 @@ import { NodeTypes } from '@/node-types';
|
|||||||
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
||||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||||
|
|
||||||
|
import { WebhookService } from './webhook.service';
|
||||||
import type {
|
import type {
|
||||||
IWebhookResponseCallbackData,
|
IWebhookResponseCallbackData,
|
||||||
IWebhookManager,
|
IWebhookManager,
|
||||||
@@ -38,6 +38,7 @@ export class WaitingWebhooks implements IWebhookManager {
|
|||||||
protected readonly logger: Logger,
|
protected readonly logger: Logger,
|
||||||
protected readonly nodeTypes: NodeTypes,
|
protected readonly nodeTypes: NodeTypes,
|
||||||
private readonly executionRepository: ExecutionRepository,
|
private readonly executionRepository: ExecutionRepository,
|
||||||
|
private readonly webhookService: WebhookService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// TODO: implement `getWebhookMethods` for CORS support
|
// TODO: implement `getWebhookMethods` for CORS support
|
||||||
@@ -164,17 +165,15 @@ export class WaitingWebhooks implements IWebhookManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const additionalData = await WorkflowExecuteAdditionalData.getBase();
|
const additionalData = await WorkflowExecuteAdditionalData.getBase();
|
||||||
const webhookData = NodeHelpers.getNodeWebhooks(
|
const webhookData = this.webhookService
|
||||||
workflow,
|
.getNodeWebhooks(workflow, workflowStartNode, additionalData)
|
||||||
workflowStartNode,
|
.find(
|
||||||
additionalData,
|
(webhook) =>
|
||||||
).find(
|
webhook.httpMethod === req.method &&
|
||||||
(webhook) =>
|
webhook.path === (suffix ?? '') &&
|
||||||
webhook.httpMethod === req.method &&
|
webhook.webhookDescription.restartWebhook === true &&
|
||||||
webhook.path === (suffix ?? '') &&
|
(webhook.webhookDescription.isForm || false) === this.includeForms,
|
||||||
webhook.webhookDescription.restartWebhook === true &&
|
);
|
||||||
(webhook.webhookDescription.isForm || false) === this.includeForms,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (webhookData === undefined) {
|
if (webhookData === undefined) {
|
||||||
// If no data got found it means that the execution can not be started via a webhook.
|
// If no data got found it means that the execution can not be started via a webhook.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import { BinaryDataService, ErrorReporter, NodeExecuteFunctions } from 'n8n-core';
|
import { BinaryDataService, ErrorReporter } from 'n8n-core';
|
||||||
import type {
|
import type {
|
||||||
IBinaryData,
|
IBinaryData,
|
||||||
IBinaryKeyData,
|
IBinaryKeyData,
|
||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
createDeferredPromise,
|
createDeferredPromise,
|
||||||
ExecutionCancelledError,
|
ExecutionCancelledError,
|
||||||
FORM_NODE_TYPE,
|
FORM_NODE_TYPE,
|
||||||
NodeHelpers,
|
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { finished } from 'stream/promises';
|
import { finished } from 'stream/promises';
|
||||||
@@ -57,6 +56,7 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da
|
|||||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||||
import { WorkflowRunner } from '@/workflow-runner';
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
|
|
||||||
|
import { WebhookService } from './webhook.service';
|
||||||
import type { IWebhookResponseCallbackData, WebhookRequest } from './webhook.types';
|
import type { IWebhookResponseCallbackData, WebhookRequest } from './webhook.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,7 +88,12 @@ export function getWorkflowWebhooks(
|
|||||||
}
|
}
|
||||||
returnData.push.apply(
|
returnData.push.apply(
|
||||||
returnData,
|
returnData,
|
||||||
NodeHelpers.getNodeWebhooks(workflow, node, additionalData, ignoreRestartWebhooks),
|
Container.get(WebhookService).getNodeWebhooks(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
additionalData,
|
||||||
|
ignoreRestartWebhooks,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,11 +259,11 @@ export async function executeWebhook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
webhookResultData = await workflow.runWebhook(
|
webhookResultData = await Container.get(WebhookService).runWebhook(
|
||||||
|
workflow,
|
||||||
webhookData,
|
webhookData,
|
||||||
workflowStartNode,
|
workflowStartNode,
|
||||||
additionalData,
|
additionalData,
|
||||||
NodeExecuteFunctions,
|
|
||||||
executionMode,
|
executionMode,
|
||||||
runExecutionData ?? null,
|
runExecutionData ?? null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import type { IHttpRequestMethods } from 'n8n-workflow';
|
import { HookContext, WebhookContext } from 'n8n-core';
|
||||||
|
import { ApplicationError, Node, NodeHelpers } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
IHttpRequestMethods,
|
||||||
|
INode,
|
||||||
|
IRunExecutionData,
|
||||||
|
IWebhookData,
|
||||||
|
IWebhookResponseData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
WebhookSetupMethodNames,
|
||||||
|
Workflow,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import type { WebhookEntity } from '@/databases/entities/webhook-entity';
|
import type { WebhookEntity } from '@/databases/entities/webhook-entity';
|
||||||
import { WebhookRepository } from '@/databases/repositories/webhook.repository';
|
import { WebhookRepository } from '@/databases/repositories/webhook.repository';
|
||||||
|
import { Logger } from '@/logging/logger.service';
|
||||||
|
import { NodeTypes } from '@/node-types';
|
||||||
import { CacheService } from '@/services/cache/cache.service';
|
import { CacheService } from '@/services/cache/cache.service';
|
||||||
|
|
||||||
type Method = NonNullable<IHttpRequestMethods>;
|
type Method = NonNullable<IHttpRequestMethods>;
|
||||||
@@ -10,8 +25,10 @@ type Method = NonNullable<IHttpRequestMethods>;
|
|||||||
@Service()
|
@Service()
|
||||||
export class WebhookService {
|
export class WebhookService {
|
||||||
constructor(
|
constructor(
|
||||||
private webhookRepository: WebhookRepository,
|
private readonly logger: Logger,
|
||||||
private cacheService: CacheService,
|
private readonly webhookRepository: WebhookRepository,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly nodeTypes: NodeTypes,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async populateCache() {
|
async populateCache() {
|
||||||
@@ -118,4 +135,210 @@ export class WebhookService {
|
|||||||
.find({ select: ['method'], where: { webhookPath: path } })
|
.find({ select: ['method'], where: { webhookPath: path } })
|
||||||
.then((rows) => rows.map((r) => r.method));
|
.then((rows) => rows.map((r) => r.method));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all the webhooks which should be created for the give node
|
||||||
|
*/
|
||||||
|
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 = this.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) {
|
||||||
|
this.logger.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 = NodeHelpers.getNodeWebhookPath(
|
||||||
|
workflowId,
|
||||||
|
node,
|
||||||
|
nodeWebhookPath,
|
||||||
|
isFullPath,
|
||||||
|
restartWebhook,
|
||||||
|
);
|
||||||
|
|
||||||
|
const webhookMethods = workflow.expression.getSimpleParameterValue(
|
||||||
|
node,
|
||||||
|
webhookDescription.httpMethod,
|
||||||
|
mode,
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
'GET',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (webhookMethods === undefined) {
|
||||||
|
this.logger.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWebhookIfNotExists(
|
||||||
|
workflow: Workflow,
|
||||||
|
webhookData: IWebhookData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
activation: WorkflowActivateMode,
|
||||||
|
): Promise<void> {
|
||||||
|
const webhookExists = await this.runWebhookMethod(
|
||||||
|
'checkExists',
|
||||||
|
workflow,
|
||||||
|
webhookData,
|
||||||
|
mode,
|
||||||
|
activation,
|
||||||
|
);
|
||||||
|
if (!webhookExists) {
|
||||||
|
// If webhook does not exist yet create it
|
||||||
|
await this.runWebhookMethod('create', workflow, webhookData, mode, activation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWebhook(
|
||||||
|
workflow: Workflow,
|
||||||
|
webhookData: IWebhookData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
activation: WorkflowActivateMode,
|
||||||
|
) {
|
||||||
|
await this.runWebhookMethod('delete', workflow, webhookData, mode, activation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runWebhookMethod(
|
||||||
|
method: WebhookSetupMethodNames,
|
||||||
|
workflow: Workflow,
|
||||||
|
webhookData: IWebhookData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
activation: WorkflowActivateMode,
|
||||||
|
): Promise<boolean | undefined> {
|
||||||
|
const node = workflow.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 context = new HookContext(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
webhookData.workflowExecuteAdditionalData,
|
||||||
|
mode,
|
||||||
|
activation,
|
||||||
|
webhookData,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (await webhookFn.call(context)) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the webhook data to see what it should return and if the
|
||||||
|
* workflow should be started or not
|
||||||
|
*/
|
||||||
|
async runWebhook(
|
||||||
|
workflow: Workflow,
|
||||||
|
webhookData: IWebhookData,
|
||||||
|
node: INode,
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
runExecutionData: IRunExecutionData | null,
|
||||||
|
): Promise<IWebhookResponseData> {
|
||||||
|
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||||
|
if (nodeType.webhook === undefined) {
|
||||||
|
throw new ApplicationError('Node does not have any webhooks defined', {
|
||||||
|
extra: { nodeName: node.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = new WebhookContext(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
additionalData,
|
||||||
|
mode,
|
||||||
|
webhookData,
|
||||||
|
[],
|
||||||
|
runExecutionData ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return nodeType instanceof Node
|
||||||
|
? await nodeType.webhook(context)
|
||||||
|
: ((await nodeType.webhook.call(context)) as IWebhookResponseData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type { InstanceSettings } from 'n8n-core';
|
|
||||||
import { NodeApiError, Workflow } from 'n8n-workflow';
|
import { NodeApiError, Workflow } from 'n8n-workflow';
|
||||||
import type { IWebhookData, WorkflowActivateMode } from 'n8n-workflow';
|
import type { IWebhookData, WorkflowActivateMode } from 'n8n-workflow';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
@@ -10,6 +9,7 @@ import type { WebhookEntity } from '@/databases/entities/webhook-entity';
|
|||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
import { ExecutionService } from '@/executions/execution.service';
|
import { ExecutionService } from '@/executions/execution.service';
|
||||||
import { ExternalHooks } from '@/external-hooks';
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
|
import { Logger } from '@/logging/logger.service';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
import { Push } from '@/push';
|
import { Push } from '@/push';
|
||||||
import { SecretsHelper } from '@/secrets-helpers';
|
import { SecretsHelper } from '@/secrets-helpers';
|
||||||
@@ -25,6 +25,7 @@ import * as utils from './shared/utils/';
|
|||||||
import { mockInstance } from '../shared/mocking';
|
import { mockInstance } from '../shared/mocking';
|
||||||
|
|
||||||
mockInstance(ActiveExecutions);
|
mockInstance(ActiveExecutions);
|
||||||
|
mockInstance(Logger);
|
||||||
mockInstance(Push);
|
mockInstance(Push);
|
||||||
mockInstance(SecretsHelper);
|
mockInstance(SecretsHelper);
|
||||||
mockInstance(ExecutionService);
|
mockInstance(ExecutionService);
|
||||||
@@ -85,7 +86,7 @@ describe('init()', () => {
|
|||||||
await Promise.all([createActiveWorkflow(), createActiveWorkflow()]);
|
await Promise.all([createActiveWorkflow(), createActiveWorkflow()]);
|
||||||
|
|
||||||
const checkSpy = jest
|
const checkSpy = jest
|
||||||
.spyOn(Workflow.prototype, 'checkIfWorkflowCanBeActivated')
|
.spyOn(activeWorkflowManager, 'checkIfWorkflowCanBeActivated')
|
||||||
.mockReturnValue(true);
|
.mockReturnValue(true);
|
||||||
|
|
||||||
await activeWorkflowManager.init();
|
await activeWorkflowManager.init();
|
||||||
@@ -166,7 +167,6 @@ describe('remove()', () => {
|
|||||||
|
|
||||||
it('should remove all webhooks of a workflow from external service', async () => {
|
it('should remove all webhooks of a workflow from external service', async () => {
|
||||||
const dbWorkflow = await createActiveWorkflow();
|
const dbWorkflow = await createActiveWorkflow();
|
||||||
const deleteWebhookSpy = jest.spyOn(Workflow.prototype, 'deleteWebhook');
|
|
||||||
jest
|
jest
|
||||||
.spyOn(WebhookHelpers, 'getWorkflowWebhooks')
|
.spyOn(WebhookHelpers, 'getWorkflowWebhooks')
|
||||||
.mockReturnValue([mock<IWebhookData>({ path: 'some-path' })]);
|
.mockReturnValue([mock<IWebhookData>({ path: 'some-path' })]);
|
||||||
@@ -174,7 +174,7 @@ describe('remove()', () => {
|
|||||||
await activeWorkflowManager.init();
|
await activeWorkflowManager.init();
|
||||||
await activeWorkflowManager.remove(dbWorkflow.id);
|
await activeWorkflowManager.remove(dbWorkflow.id);
|
||||||
|
|
||||||
expect(deleteWebhookSpy).toHaveBeenCalledTimes(1);
|
expect(webhookService.deleteWebhook).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stop running triggers and pollers', async () => {
|
it('should stop running triggers and pollers', async () => {
|
||||||
@@ -258,82 +258,11 @@ describe('addWebhooks()', () => {
|
|||||||
const [node] = dbWorkflow.nodes;
|
const [node] = dbWorkflow.nodes;
|
||||||
|
|
||||||
jest.spyOn(Workflow.prototype, 'getNode').mockReturnValue(node);
|
jest.spyOn(Workflow.prototype, 'getNode').mockReturnValue(node);
|
||||||
jest.spyOn(Workflow.prototype, 'checkIfWorkflowCanBeActivated').mockReturnValue(true);
|
jest.spyOn(activeWorkflowManager, 'checkIfWorkflowCanBeActivated').mockReturnValue(true);
|
||||||
jest.spyOn(Workflow.prototype, 'createWebhookIfNotExists').mockResolvedValue(undefined);
|
webhookService.createWebhookIfNotExists.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await activeWorkflowManager.addWebhooks(workflow, additionalData, 'trigger', 'init');
|
await activeWorkflowManager.addWebhooks(workflow, additionalData, 'trigger', 'init');
|
||||||
|
|
||||||
expect(webhookService.storeWebhook).toHaveBeenCalledTimes(1);
|
expect(webhookService.storeWebhook).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('shouldAddWebhooks', () => {
|
|
||||||
describe('if leader', () => {
|
|
||||||
const activeWorkflowManager = new ActiveWorkflowManager(
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock<InstanceSettings>({ isLeader: true, isFollower: false }),
|
|
||||||
mock(),
|
|
||||||
);
|
|
||||||
|
|
||||||
test('should return `true` for `init`', () => {
|
|
||||||
// ensure webhooks are populated on init: https://github.com/n8n-io/n8n/pull/8830
|
|
||||||
const result = activeWorkflowManager.shouldAddWebhooks('init');
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return `false` for `leadershipChange`', () => {
|
|
||||||
const result = activeWorkflowManager.shouldAddWebhooks('leadershipChange');
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return `true` for `update` or `activate`', () => {
|
|
||||||
const modes = ['update', 'activate'] as WorkflowActivateMode[];
|
|
||||||
for (const mode of modes) {
|
|
||||||
const result = activeWorkflowManager.shouldAddWebhooks(mode);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('if follower', () => {
|
|
||||||
const activeWorkflowManager = new ActiveWorkflowManager(
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock(),
|
|
||||||
mock<InstanceSettings>({ isLeader: false, isFollower: true }),
|
|
||||||
mock(),
|
|
||||||
);
|
|
||||||
|
|
||||||
test('should return `false` for `update` or `activate`', () => {
|
|
||||||
const modes = ['update', 'activate'] as WorkflowActivateMode[];
|
|
||||||
for (const mode of modes) {
|
|
||||||
const result = activeWorkflowManager.shouldAddWebhooks(mode);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ import { Service } from 'typedi';
|
|||||||
import { ErrorReporter } from './error-reporter';
|
import { ErrorReporter } from './error-reporter';
|
||||||
import type { IWorkflowData } from './Interfaces';
|
import type { IWorkflowData } from './Interfaces';
|
||||||
import { ScheduledTaskManager } from './ScheduledTaskManager';
|
import { ScheduledTaskManager } from './ScheduledTaskManager';
|
||||||
|
import { TriggersAndPollers } from './TriggersAndPollers';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class ActiveWorkflows {
|
export class ActiveWorkflows {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly scheduledTaskManager: ScheduledTaskManager,
|
private readonly scheduledTaskManager: ScheduledTaskManager,
|
||||||
|
private readonly triggersAndPollers: TriggersAndPollers,
|
||||||
private readonly errorReporter: ErrorReporter,
|
private readonly errorReporter: ErrorReporter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -78,7 +80,8 @@ export class ActiveWorkflows {
|
|||||||
|
|
||||||
for (const triggerNode of triggerNodes) {
|
for (const triggerNode of triggerNodes) {
|
||||||
try {
|
try {
|
||||||
triggerResponse = await workflow.runTrigger(
|
triggerResponse = await this.triggersAndPollers.runTrigger(
|
||||||
|
workflow,
|
||||||
triggerNode,
|
triggerNode,
|
||||||
getTriggerFunctions,
|
getTriggerFunctions,
|
||||||
additionalData,
|
additionalData,
|
||||||
@@ -153,7 +156,7 @@ export class ActiveWorkflows {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pollResponse = await workflow.runPoll(node, pollFunctions);
|
const pollResponse = await this.triggersAndPollers.runPoll(workflow, node, pollFunctions);
|
||||||
|
|
||||||
if (pollResponse !== null) {
|
if (pollResponse !== null) {
|
||||||
pollFunctions.__emit(pollResponse);
|
pollFunctions.__emit(pollResponse);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { INodeProperties } from 'n8n-workflow';
|
||||||
|
import { cronNodeOptions } from 'n8n-workflow';
|
||||||
|
|
||||||
export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS';
|
export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS';
|
||||||
export const PLACEHOLDER_EMPTY_EXECUTION_ID = '__UNKNOWN__';
|
export const PLACEHOLDER_EMPTY_EXECUTION_ID = '__UNKNOWN__';
|
||||||
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
|
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
|
||||||
@@ -12,3 +15,30 @@ export const CONFIG_FILES = 'N8N_CONFIG_FILES';
|
|||||||
export const BINARY_DATA_STORAGE_PATH = 'N8N_BINARY_DATA_STORAGE_PATH';
|
export const BINARY_DATA_STORAGE_PATH = 'N8N_BINARY_DATA_STORAGE_PATH';
|
||||||
export const UM_EMAIL_TEMPLATES_INVITE = 'N8N_UM_EMAIL_TEMPLATES_INVITE';
|
export const UM_EMAIL_TEMPLATES_INVITE = 'N8N_UM_EMAIL_TEMPLATES_INVITE';
|
||||||
export const UM_EMAIL_TEMPLATES_PWRESET = 'N8N_UM_EMAIL_TEMPLATES_PWRESET';
|
export const UM_EMAIL_TEMPLATES_PWRESET = 'N8N_UM_EMAIL_TEMPLATES_PWRESET';
|
||||||
|
|
||||||
|
export 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.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ICredentialType,
|
ICredentialType,
|
||||||
ICredentialTypeData,
|
ICredentialTypeData,
|
||||||
INodeCredentialDescription,
|
INodeCredentialDescription,
|
||||||
|
INodePropertyOptions,
|
||||||
INodeType,
|
INodeType,
|
||||||
INodeTypeBaseDescription,
|
INodeTypeBaseDescription,
|
||||||
INodeTypeData,
|
INodeTypeData,
|
||||||
@@ -14,13 +15,18 @@ import type {
|
|||||||
IVersionedNodeType,
|
IVersionedNodeType,
|
||||||
KnownNodesAndCredentials,
|
KnownNodesAndCredentials,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { ApplicationError, LoggerProxy as Logger, NodeHelpers, jsonParse } from 'n8n-workflow';
|
import {
|
||||||
|
ApplicationError,
|
||||||
|
LoggerProxy as Logger,
|
||||||
|
applyDeclarativeNodeOptionParameters,
|
||||||
|
jsonParse,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { loadClassInIsolation } from './ClassLoader';
|
import { loadClassInIsolation } from './ClassLoader';
|
||||||
import { CUSTOM_NODES_CATEGORY } from './Constants';
|
import { commonCORSParameters, commonPollingParameters, CUSTOM_NODES_CATEGORY } from './Constants';
|
||||||
import { UnrecognizedCredentialTypeError } from './errors/unrecognized-credential-type.error';
|
import { UnrecognizedCredentialTypeError } from './errors/unrecognized-credential-type.error';
|
||||||
import { UnrecognizedNodeTypeError } from './errors/unrecognized-node-type.error';
|
import { UnrecognizedNodeTypeError } from './errors/unrecognized-node-type.error';
|
||||||
import type { n8n } from './Interfaces';
|
import type { n8n } from './Interfaces';
|
||||||
@@ -135,7 +141,7 @@ export abstract class DirectoryLoader {
|
|||||||
|
|
||||||
for (const version of Object.values(tempNode.nodeVersions)) {
|
for (const version of Object.values(tempNode.nodeVersions)) {
|
||||||
this.addLoadOptionsMethods(version);
|
this.addLoadOptionsMethods(version);
|
||||||
NodeHelpers.applySpecialNodeParameters(version);
|
this.applySpecialNodeParameters(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVersionNode = tempNode.nodeVersions[tempNode.currentVersion];
|
const currentVersionNode = tempNode.nodeVersions[tempNode.currentVersion];
|
||||||
@@ -150,7 +156,7 @@ export abstract class DirectoryLoader {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.addLoadOptionsMethods(tempNode);
|
this.addLoadOptionsMethods(tempNode);
|
||||||
NodeHelpers.applySpecialNodeParameters(tempNode);
|
this.applySpecialNodeParameters(tempNode);
|
||||||
|
|
||||||
// Short renaming to avoid type issues
|
// Short renaming to avoid type issues
|
||||||
nodeVersion = Array.isArray(tempNode.description.version)
|
nodeVersion = Array.isArray(tempNode.description.version)
|
||||||
@@ -346,6 +352,24 @@ export abstract class DirectoryLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private 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);
|
||||||
|
}
|
||||||
|
|
||||||
private getIconPath(icon: string, filePath: string) {
|
private getIconPath(icon: string, filePath: string) {
|
||||||
const iconPath = path.join(path.dirname(filePath), icon.replace('file:', ''));
|
const iconPath = path.join(path.dirname(filePath), icon.replace('file:', ''));
|
||||||
return `icons/${this.packageName}/${iconPath}`;
|
return `icons/${this.packageName}/${iconPath}`;
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ import type {
|
|||||||
IDataObject,
|
IDataObject,
|
||||||
IExecuteData,
|
IExecuteData,
|
||||||
IExecuteFunctions,
|
IExecuteFunctions,
|
||||||
IExecuteSingleFunctions,
|
|
||||||
IHookFunctions,
|
|
||||||
IHttpRequestOptions,
|
IHttpRequestOptions,
|
||||||
IN8nHttpFullResponse,
|
IN8nHttpFullResponse,
|
||||||
IN8nHttpResponse,
|
IN8nHttpResponse,
|
||||||
@@ -56,9 +54,7 @@ import type {
|
|||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
ITaskDataConnections,
|
ITaskDataConnections,
|
||||||
ITriggerFunctions,
|
ITriggerFunctions,
|
||||||
IWebhookData,
|
|
||||||
IWebhookDescription,
|
IWebhookDescription,
|
||||||
IWebhookFunctions,
|
|
||||||
IWorkflowDataProxyAdditionalKeys,
|
IWorkflowDataProxyAdditionalKeys,
|
||||||
IWorkflowExecuteAdditionalData,
|
IWorkflowExecuteAdditionalData,
|
||||||
NodeExecutionWithMetadata,
|
NodeExecutionWithMetadata,
|
||||||
@@ -121,15 +117,7 @@ import { DataDeduplicationService } from './data-deduplication-service';
|
|||||||
import { InstanceSettings } from './InstanceSettings';
|
import { InstanceSettings } from './InstanceSettings';
|
||||||
import type { IResponseError } from './Interfaces';
|
import type { IResponseError } from './Interfaces';
|
||||||
// eslint-disable-next-line import/no-cycle
|
// eslint-disable-next-line import/no-cycle
|
||||||
import {
|
import { PollContext, SupplyDataContext, TriggerContext } from './node-execution-context';
|
||||||
ExecuteContext,
|
|
||||||
ExecuteSingleContext,
|
|
||||||
HookContext,
|
|
||||||
PollContext,
|
|
||||||
SupplyDataContext,
|
|
||||||
TriggerContext,
|
|
||||||
WebhookContext,
|
|
||||||
} from './node-execution-context';
|
|
||||||
import { ScheduledTaskManager } from './ScheduledTaskManager';
|
import { ScheduledTaskManager } from './ScheduledTaskManager';
|
||||||
import { SSHClientsManager } from './SSHClientsManager';
|
import { SSHClientsManager } from './SSHClientsManager';
|
||||||
|
|
||||||
@@ -2720,68 +2708,6 @@ export function getExecuteTriggerFunctions(
|
|||||||
return new TriggerContext(workflow, node, additionalData, mode, activation);
|
return new TriggerContext(workflow, node, additionalData, mode, activation);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the execute functions regular nodes have access to.
|
|
||||||
*/
|
|
||||||
export function getExecuteFunctions(
|
|
||||||
workflow: Workflow,
|
|
||||||
runExecutionData: IRunExecutionData,
|
|
||||||
runIndex: number,
|
|
||||||
connectionInputData: INodeExecutionData[],
|
|
||||||
inputData: ITaskDataConnections,
|
|
||||||
node: INode,
|
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
|
||||||
executeData: IExecuteData,
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
closeFunctions: CloseFunction[],
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): IExecuteFunctions {
|
|
||||||
return new ExecuteContext(
|
|
||||||
workflow,
|
|
||||||
node,
|
|
||||||
additionalData,
|
|
||||||
mode,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
connectionInputData,
|
|
||||||
inputData,
|
|
||||||
executeData,
|
|
||||||
closeFunctions,
|
|
||||||
abortSignal,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the execute functions regular nodes have access to when single-function is defined.
|
|
||||||
*/
|
|
||||||
export function getExecuteSingleFunctions(
|
|
||||||
workflow: Workflow,
|
|
||||||
runExecutionData: IRunExecutionData,
|
|
||||||
runIndex: number,
|
|
||||||
connectionInputData: INodeExecutionData[],
|
|
||||||
inputData: ITaskDataConnections,
|
|
||||||
node: INode,
|
|
||||||
itemIndex: number,
|
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
|
||||||
executeData: IExecuteData,
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): IExecuteSingleFunctions {
|
|
||||||
return new ExecuteSingleContext(
|
|
||||||
workflow,
|
|
||||||
node,
|
|
||||||
additionalData,
|
|
||||||
mode,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
connectionInputData,
|
|
||||||
inputData,
|
|
||||||
itemIndex,
|
|
||||||
executeData,
|
|
||||||
abortSignal,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCredentialTestFunctions(): ICredentialTestFunctions {
|
export function getCredentialTestFunctions(): ICredentialTestFunctions {
|
||||||
return {
|
return {
|
||||||
helpers: {
|
helpers: {
|
||||||
@@ -2792,41 +2718,3 @@ export function getCredentialTestFunctions(): ICredentialTestFunctions {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the execute functions regular nodes have access to in hook-function.
|
|
||||||
*/
|
|
||||||
export function getExecuteHookFunctions(
|
|
||||||
workflow: Workflow,
|
|
||||||
node: INode,
|
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
activation: WorkflowActivateMode,
|
|
||||||
webhookData?: IWebhookData,
|
|
||||||
): IHookFunctions {
|
|
||||||
return new HookContext(workflow, node, additionalData, mode, activation, webhookData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the execute functions regular nodes have access to when webhook-function is defined.
|
|
||||||
*/
|
|
||||||
// TODO: check where it is used and make sure close functions are called
|
|
||||||
export function getExecuteWebhookFunctions(
|
|
||||||
workflow: Workflow,
|
|
||||||
node: INode,
|
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
webhookData: IWebhookData,
|
|
||||||
closeFunctions: CloseFunction[],
|
|
||||||
runExecutionData: IRunExecutionData | null,
|
|
||||||
): IWebhookFunctions {
|
|
||||||
return new WebhookContext(
|
|
||||||
workflow,
|
|
||||||
node,
|
|
||||||
additionalData,
|
|
||||||
mode,
|
|
||||||
webhookData,
|
|
||||||
closeFunctions,
|
|
||||||
runExecutionData,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import merge from 'lodash/merge';
|
import merge from 'lodash/merge';
|
||||||
import set from 'lodash/set';
|
import set from 'lodash/set';
|
||||||
import url from 'node:url';
|
import { NodeHelpers, NodeApiError, NodeOperationError, sleep } from 'n8n-workflow';
|
||||||
|
|
||||||
import { NodeApiError } from './errors/node-api.error';
|
|
||||||
import { NodeOperationError } from './errors/node-operation.error';
|
|
||||||
import type {
|
import type {
|
||||||
ICredentialDataDecryptedObject,
|
ICredentialDataDecryptedObject,
|
||||||
ICredentialsDecrypted,
|
ICredentialsDecrypted,
|
||||||
IHttpRequestOptions,
|
IHttpRequestOptions,
|
||||||
IN8nHttpFullResponse,
|
IN8nHttpFullResponse,
|
||||||
INode,
|
INode,
|
||||||
INodeExecuteFunctions,
|
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodePropertyOptions,
|
INodePropertyOptions,
|
||||||
@@ -43,10 +36,11 @@ import type {
|
|||||||
CloseFunction,
|
CloseFunction,
|
||||||
INodeCredentialDescription,
|
INodeCredentialDescription,
|
||||||
IExecutePaginationFunctions,
|
IExecutePaginationFunctions,
|
||||||
} from './Interfaces';
|
Workflow,
|
||||||
import * as NodeHelpers from './NodeHelpers';
|
} from 'n8n-workflow';
|
||||||
import { sleep } from './utils';
|
import url from 'node:url';
|
||||||
import type { Workflow } from './Workflow';
|
|
||||||
|
import { ExecuteContext, ExecuteSingleContext } from './node-execution-context';
|
||||||
|
|
||||||
export class RoutingNode {
|
export class RoutingNode {
|
||||||
additionalData: IWorkflowExecuteAdditionalData;
|
additionalData: IWorkflowExecuteAdditionalData;
|
||||||
@@ -83,7 +77,6 @@ export class RoutingNode {
|
|||||||
runIndex: number,
|
runIndex: number,
|
||||||
nodeType: INodeType,
|
nodeType: INodeType,
|
||||||
executeData: IExecuteData,
|
executeData: IExecuteData,
|
||||||
nodeExecuteFunctions: INodeExecuteFunctions,
|
|
||||||
credentialsDecrypted?: ICredentialsDecrypted,
|
credentialsDecrypted?: ICredentialsDecrypted,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<INodeExecutionData[][] | null | undefined> {
|
): Promise<INodeExecutionData[][] | null | undefined> {
|
||||||
@@ -91,16 +84,16 @@ export class RoutingNode {
|
|||||||
const returnData: INodeExecutionData[] = [];
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
const closeFunctions: CloseFunction[] = [];
|
const closeFunctions: CloseFunction[] = [];
|
||||||
const executeFunctions = nodeExecuteFunctions.getExecuteFunctions(
|
const executeFunctions = new ExecuteContext(
|
||||||
this.workflow,
|
this.workflow,
|
||||||
|
this.node,
|
||||||
|
this.additionalData,
|
||||||
|
this.mode,
|
||||||
this.runExecutionData,
|
this.runExecutionData,
|
||||||
runIndex,
|
runIndex,
|
||||||
this.connectionInputData,
|
this.connectionInputData,
|
||||||
inputData,
|
inputData,
|
||||||
this.node,
|
|
||||||
this.additionalData,
|
|
||||||
executeData,
|
executeData,
|
||||||
this.mode,
|
|
||||||
closeFunctions,
|
closeFunctions,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
);
|
);
|
||||||
@@ -136,6 +129,7 @@ export class RoutingNode {
|
|||||||
credentials =
|
credentials =
|
||||||
(await executeFunctions.getCredentials<ICredentialDataDecryptedObject>(
|
(await executeFunctions.getCredentials<ICredentialDataDecryptedObject>(
|
||||||
credentialDescription.name,
|
credentialDescription.name,
|
||||||
|
0,
|
||||||
)) || {};
|
)) || {};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (credentialDescription.required) {
|
if (credentialDescription.required) {
|
||||||
@@ -168,20 +162,22 @@ export class RoutingNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thisArgs = new ExecuteSingleContext(
|
||||||
|
this.workflow,
|
||||||
|
this.node,
|
||||||
|
this.additionalData,
|
||||||
|
this.mode,
|
||||||
|
this.runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
this.connectionInputData,
|
||||||
|
inputData,
|
||||||
|
itemIndex,
|
||||||
|
executeData,
|
||||||
|
abortSignal,
|
||||||
|
);
|
||||||
|
|
||||||
itemContext.push({
|
itemContext.push({
|
||||||
thisArgs: nodeExecuteFunctions.getExecuteSingleFunctions(
|
thisArgs,
|
||||||
this.workflow,
|
|
||||||
this.runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
this.connectionInputData,
|
|
||||||
inputData,
|
|
||||||
this.node,
|
|
||||||
itemIndex,
|
|
||||||
this.additionalData,
|
|
||||||
executeData,
|
|
||||||
this.mode,
|
|
||||||
abortSignal,
|
|
||||||
),
|
|
||||||
requestData: {
|
requestData: {
|
||||||
options: {
|
options: {
|
||||||
qs: {},
|
qs: {},
|
||||||
@@ -308,6 +304,7 @@ export class RoutingNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promisesResponses = await Promise.allSettled(requestPromises);
|
const promisesResponses = await Promise.allSettled(requestPromises);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let responseData: any;
|
let responseData: any;
|
||||||
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
|
||||||
responseData = promisesResponses.shift();
|
responseData = promisesResponses.shift();
|
||||||
116
packages/core/src/TriggersAndPollers.ts
Normal file
116
packages/core/src/TriggersAndPollers.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
Workflow,
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
IPollFunctions,
|
||||||
|
IGetExecuteTriggerFunctions,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
ITriggerResponse,
|
||||||
|
IDeferredPromise,
|
||||||
|
IExecuteResponsePromiseData,
|
||||||
|
IRun,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class TriggersAndPollers {
|
||||||
|
/**
|
||||||
|
* Runs the given trigger node so that it can trigger the workflow when the node has data.
|
||||||
|
*/
|
||||||
|
async runTrigger(
|
||||||
|
workflow: Workflow,
|
||||||
|
node: INode,
|
||||||
|
getTriggerFunctions: IGetExecuteTriggerFunctions,
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
activation: WorkflowActivateMode,
|
||||||
|
): Promise<ITriggerResponse | undefined> {
|
||||||
|
const triggerFunctions = getTriggerFunctions(workflow, node, additionalData, mode, activation);
|
||||||
|
|
||||||
|
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||||
|
|
||||||
|
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 poller node so that it can trigger the workflow when the node has data.
|
||||||
|
*/
|
||||||
|
async runPoll(
|
||||||
|
workflow: Workflow,
|
||||||
|
node: INode,
|
||||||
|
pollFunctions: IPollFunctions,
|
||||||
|
): Promise<INodeExecutionData[][] | null> {
|
||||||
|
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ import type {
|
|||||||
StartNodeData,
|
StartNodeData,
|
||||||
NodeExecutionHint,
|
NodeExecutionHint,
|
||||||
NodeInputConnections,
|
NodeInputConnections,
|
||||||
|
IRunNodeResponse,
|
||||||
|
IWorkflowIssues,
|
||||||
|
INodeIssues,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
LoggerProxy as Logger,
|
LoggerProxy as Logger,
|
||||||
@@ -47,11 +50,13 @@ import {
|
|||||||
NodeExecutionOutput,
|
NodeExecutionOutput,
|
||||||
sleep,
|
sleep,
|
||||||
ExecutionCancelledError,
|
ExecutionCancelledError,
|
||||||
|
Node,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import PCancelable from 'p-cancelable';
|
import PCancelable from 'p-cancelable';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
|
||||||
import { ErrorReporter } from './error-reporter';
|
import { ErrorReporter } from './error-reporter';
|
||||||
|
import { ExecuteContext, PollContext } from './node-execution-context';
|
||||||
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
|
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
|
||||||
import {
|
import {
|
||||||
DirectedGraph,
|
DirectedGraph,
|
||||||
@@ -63,6 +68,8 @@ import {
|
|||||||
handleCycles,
|
handleCycles,
|
||||||
filterDisabledNodes,
|
filterDisabledNodes,
|
||||||
} from './PartialExecutionUtils';
|
} from './PartialExecutionUtils';
|
||||||
|
import { RoutingNode } from './RoutingNode';
|
||||||
|
import { TriggersAndPollers } from './TriggersAndPollers';
|
||||||
|
|
||||||
export class WorkflowExecute {
|
export class WorkflowExecute {
|
||||||
private status: ExecutionStatus = 'new';
|
private status: ExecutionStatus = 'new';
|
||||||
@@ -884,6 +891,280 @@ export class WorkflowExecute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
workflow: Workflow,
|
||||||
|
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 = workflow.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 = workflow.getChildNodes(inputData.startNode);
|
||||||
|
checkNodes.push(inputData.startNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nodeName of checkNodes) {
|
||||||
|
let nodeIssues: INodeIssues | null = null;
|
||||||
|
const node = workflow.nodes[nodeName];
|
||||||
|
|
||||||
|
if (node.disabled === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeType = workflow.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Executes the given node */
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
async runNode(
|
||||||
|
workflow: Workflow,
|
||||||
|
executionData: IExecuteData,
|
||||||
|
runExecutionData: IRunExecutionData,
|
||||||
|
runIndex: number,
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
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 = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||||
|
|
||||||
|
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 = workflow.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 = new ExecuteContext(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
additionalData,
|
||||||
|
mode,
|
||||||
|
runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
connectionInputData,
|
||||||
|
inputData,
|
||||||
|
executionData,
|
||||||
|
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')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
.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 context = new PollContext(workflow, node, additionalData, mode, 'manual');
|
||||||
|
return { data: await nodeType.poll.call(context) };
|
||||||
|
}
|
||||||
|
// 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 Container.get(TriggersAndPollers).runTrigger(
|
||||||
|
workflow,
|
||||||
|
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(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
connectionInputData,
|
||||||
|
runExecutionData ?? null,
|
||||||
|
additionalData,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: await routingNode.runNode(
|
||||||
|
inputData,
|
||||||
|
runIndex,
|
||||||
|
nodeType,
|
||||||
|
executionData,
|
||||||
|
undefined,
|
||||||
|
abortSignal,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the given execution data.
|
* Runs the given execution data.
|
||||||
*
|
*
|
||||||
@@ -909,7 +1190,7 @@ export class WorkflowExecute {
|
|||||||
|
|
||||||
const pinDataNodeNames = Object.keys(this.runExecutionData.resultData.pinData ?? {});
|
const pinDataNodeNames = Object.keys(this.runExecutionData.resultData.pinData ?? {});
|
||||||
|
|
||||||
const workflowIssues = workflow.checkReadyForExecution({
|
const workflowIssues = this.checkReadyForExecution(workflow, {
|
||||||
startNode,
|
startNode,
|
||||||
destinationNode,
|
destinationNode,
|
||||||
pinDataNodeNames,
|
pinDataNodeNames,
|
||||||
@@ -1171,12 +1452,12 @@ export class WorkflowExecute {
|
|||||||
workflowId: workflow.id,
|
workflowId: workflow.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
let runNodeData = await workflow.runNode(
|
let runNodeData = await this.runNode(
|
||||||
|
workflow,
|
||||||
executionData,
|
executionData,
|
||||||
this.runExecutionData,
|
this.runExecutionData,
|
||||||
runIndex,
|
runIndex,
|
||||||
this.additionalData,
|
this.additionalData,
|
||||||
NodeExecuteFunctions,
|
|
||||||
this.mode,
|
this.mode,
|
||||||
this.abortController.signal,
|
this.abortController.signal,
|
||||||
);
|
);
|
||||||
@@ -1188,12 +1469,12 @@ export class WorkflowExecute {
|
|||||||
while (didContinueOnFail && tryIndex !== maxTries - 1) {
|
while (didContinueOnFail && tryIndex !== maxTries - 1) {
|
||||||
await sleep(waitBetweenTries);
|
await sleep(waitBetweenTries);
|
||||||
|
|
||||||
runNodeData = await workflow.runNode(
|
runNodeData = await this.runNode(
|
||||||
|
workflow,
|
||||||
executionData,
|
executionData,
|
||||||
this.runExecutionData,
|
this.runExecutionData,
|
||||||
runIndex,
|
runIndex,
|
||||||
this.additionalData,
|
this.additionalData,
|
||||||
NodeExecuteFunctions,
|
|
||||||
this.mode,
|
this.mode,
|
||||||
this.abortController.signal,
|
this.abortController.signal,
|
||||||
);
|
);
|
||||||
@@ -1230,19 +1511,20 @@ export class WorkflowExecute {
|
|||||||
const closeFunctions: CloseFunction[] = [];
|
const closeFunctions: CloseFunction[] = [];
|
||||||
// Create a WorkflowDataProxy instance that we can get the data of the
|
// Create a WorkflowDataProxy instance that we can get the data of the
|
||||||
// item which did error
|
// item which did error
|
||||||
const executeFunctions = NodeExecuteFunctions.getExecuteFunctions(
|
const executeFunctions = new ExecuteContext(
|
||||||
workflow,
|
workflow,
|
||||||
|
executionData.node,
|
||||||
|
this.additionalData,
|
||||||
|
this.mode,
|
||||||
this.runExecutionData,
|
this.runExecutionData,
|
||||||
runIndex,
|
runIndex,
|
||||||
[],
|
[],
|
||||||
executionData.data,
|
executionData.data,
|
||||||
executionData.node,
|
|
||||||
this.additionalData,
|
|
||||||
executionData,
|
executionData,
|
||||||
this.mode,
|
|
||||||
closeFunctions,
|
closeFunctions,
|
||||||
this.abortController.signal,
|
this.abortController.signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dataProxy = executeFunctions.getWorkflowDataProxy(0);
|
const dataProxy = executeFunctions.getWorkflowDataProxy(0);
|
||||||
|
|
||||||
// Loop over all outputs except the error output as it would not contain data by default
|
// Loop over all outputs except the error output as it would not contain data by default
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export * from './DirectoryLoader';
|
|||||||
export * from './Interfaces';
|
export * from './Interfaces';
|
||||||
export { InstanceSettings, InstanceType } from './InstanceSettings';
|
export { InstanceSettings, InstanceType } from './InstanceSettings';
|
||||||
export * from './NodeExecuteFunctions';
|
export * from './NodeExecuteFunctions';
|
||||||
|
export * from './RoutingNode';
|
||||||
export * from './WorkflowExecute';
|
export * from './WorkflowExecute';
|
||||||
export { NodeExecuteFunctions };
|
export { NodeExecuteFunctions };
|
||||||
export * from './data-deduplication-service';
|
export * from './data-deduplication-service';
|
||||||
|
|||||||
@@ -675,7 +675,6 @@ describe('NodeExecuteFunctions', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
nock.cleanAll();
|
nock.cleanAll();
|
||||||
nock.disableNetConnect();
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import { get } from 'lodash';
|
||||||
import type {
|
import type {
|
||||||
INode,
|
|
||||||
INodeExecutionData,
|
|
||||||
INodeParameters,
|
|
||||||
DeclarativeRestApiSettings,
|
DeclarativeRestApiSettings,
|
||||||
IRunExecutionData,
|
IExecuteData,
|
||||||
INodeProperties,
|
|
||||||
IExecuteSingleFunctions,
|
IExecuteSingleFunctions,
|
||||||
IHttpRequestOptions,
|
IHttpRequestOptions,
|
||||||
ITaskDataConnections,
|
IN8nHttpFullResponse,
|
||||||
INodeExecuteFunctions,
|
IN8nHttpResponse,
|
||||||
IN8nRequestOperations,
|
IN8nRequestOperations,
|
||||||
|
INode,
|
||||||
INodeCredentialDescription,
|
INodeCredentialDescription,
|
||||||
IExecuteData,
|
INodeExecutionData,
|
||||||
|
INodeParameters,
|
||||||
|
INodeProperties,
|
||||||
|
INodeType,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
|
IRunExecutionData,
|
||||||
|
ITaskDataConnections,
|
||||||
IWorkflowExecuteAdditionalData,
|
IWorkflowExecuteAdditionalData,
|
||||||
IExecuteFunctions,
|
} from 'n8n-workflow';
|
||||||
} from '@/Interfaces';
|
import { NodeHelpers, Workflow } from 'n8n-workflow';
|
||||||
import { applyDeclarativeNodeOptionParameters } from '@/NodeHelpers';
|
|
||||||
import { RoutingNode } from '@/RoutingNode';
|
|
||||||
import * as utilsModule from '@/utils';
|
|
||||||
import { Workflow } from '@/Workflow';
|
|
||||||
|
|
||||||
import * as Helpers from './Helpers';
|
import * as executionContexts from '@/node-execution-context';
|
||||||
|
import { RoutingNode } from '@/RoutingNode';
|
||||||
|
|
||||||
|
import { NodeTypes } from './helpers';
|
||||||
|
|
||||||
const postReceiveFunction1 = async function (
|
const postReceiveFunction1 = async function (
|
||||||
this: IExecuteSingleFunctions,
|
this: IExecuteSingleFunctions,
|
||||||
@@ -42,14 +43,55 @@ const preSendFunction1 = async function (
|
|||||||
return requestOptions;
|
return requestOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getExecuteSingleFunctions = (
|
||||||
|
workflow: Workflow,
|
||||||
|
runExecutionData: IRunExecutionData,
|
||||||
|
runIndex: number,
|
||||||
|
node: INode,
|
||||||
|
itemIndex: number,
|
||||||
|
) =>
|
||||||
|
mock<executionContexts.ExecuteSingleContext>({
|
||||||
|
getItemIndex: () => itemIndex,
|
||||||
|
getNodeParameter: (parameterName: string) =>
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
describe('RoutingNode', () => {
|
describe('RoutingNode', () => {
|
||||||
|
const nodeTypes = NodeTypes();
|
||||||
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||||
|
|
||||||
test('applyDeclarativeNodeOptionParameters', () => {
|
test('applyDeclarativeNodeOptionParameters', () => {
|
||||||
const nodeTypes = Helpers.NodeTypes();
|
const nodeTypes = NodeTypes();
|
||||||
const nodeType = nodeTypes.getByNameAndVersion('test.setMulti');
|
const nodeType = nodeTypes.getByNameAndVersion('test.setMulti');
|
||||||
|
|
||||||
applyDeclarativeNodeOptionParameters(nodeType);
|
NodeHelpers.applyDeclarativeNodeOptionParameters(nodeType);
|
||||||
|
|
||||||
const options = nodeType.description.properties.find(
|
const options = nodeType.description.properties.find(
|
||||||
(property) => property.name === 'requestOptions',
|
(property) => property.name === 'requestOptions',
|
||||||
@@ -667,7 +709,7 @@ describe('RoutingNode', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const nodeTypes = Helpers.NodeTypes();
|
const nodeTypes = NodeTypes();
|
||||||
const node: INode = {
|
const node: INode = {
|
||||||
parameters: {},
|
parameters: {},
|
||||||
name: 'test',
|
name: 'test',
|
||||||
@@ -711,7 +753,7 @@ describe('RoutingNode', () => {
|
|||||||
mode,
|
mode,
|
||||||
);
|
);
|
||||||
|
|
||||||
const executeSingleFunctions = Helpers.getExecuteSingleFunctions(
|
const executeSingleFunctions = getExecuteSingleFunctions(
|
||||||
workflow,
|
workflow,
|
||||||
runExecutionData,
|
runExecutionData,
|
||||||
runIndex,
|
runIndex,
|
||||||
@@ -1861,7 +1903,6 @@ describe('RoutingNode', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const nodeTypes = Helpers.NodeTypes();
|
|
||||||
const baseNode: INode = {
|
const baseNode: INode = {
|
||||||
parameters: {},
|
parameters: {},
|
||||||
name: 'test',
|
name: 'test',
|
||||||
@@ -1877,7 +1918,7 @@ describe('RoutingNode', () => {
|
|||||||
const connectionInputData: INodeExecutionData[] = [];
|
const connectionInputData: INodeExecutionData[] = [];
|
||||||
const runExecutionData: IRunExecutionData = { resultData: { runData: {} } };
|
const runExecutionData: IRunExecutionData = { resultData: { runData: {} } };
|
||||||
const nodeType = nodeTypes.getByNameAndVersion(baseNode.type);
|
const nodeType = nodeTypes.getByNameAndVersion(baseNode.type);
|
||||||
applyDeclarativeNodeOptionParameters(nodeType);
|
NodeHelpers.applyDeclarativeNodeOptionParameters(nodeType);
|
||||||
|
|
||||||
const propertiesOriginal = nodeType.description.properties;
|
const propertiesOriginal = nodeType.description.properties;
|
||||||
|
|
||||||
@@ -1921,8 +1962,8 @@ describe('RoutingNode', () => {
|
|||||||
source: null,
|
source: null,
|
||||||
} as IExecuteData;
|
} as IExecuteData;
|
||||||
|
|
||||||
const executeFunctions = mock<IExecuteFunctions>();
|
const executeFunctions = mock<executionContexts.ExecuteContext>();
|
||||||
const executeSingleFunctions = Helpers.getExecuteSingleFunctions(
|
const executeSingleFunctions = getExecuteSingleFunctions(
|
||||||
workflow,
|
workflow,
|
||||||
runExecutionData,
|
runExecutionData,
|
||||||
runIndex,
|
runIndex,
|
||||||
@@ -1930,10 +1971,10 @@ describe('RoutingNode', () => {
|
|||||||
itemIndex,
|
itemIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodeExecuteFunctions: Partial<INodeExecuteFunctions> = {
|
jest.spyOn(executionContexts, 'ExecuteContext').mockReturnValue(executeFunctions);
|
||||||
getExecuteFunctions: () => executeFunctions,
|
jest
|
||||||
getExecuteSingleFunctions: () => executeSingleFunctions,
|
.spyOn(executionContexts, 'ExecuteSingleContext')
|
||||||
};
|
.mockReturnValue(executeSingleFunctions);
|
||||||
|
|
||||||
const numberOfItems = testData.input.specialTestOptions?.numberOfItems ?? 1;
|
const numberOfItems = testData.input.specialTestOptions?.numberOfItems ?? 1;
|
||||||
if (!inputData.main[0] || inputData.main[0].length !== numberOfItems) {
|
if (!inputData.main[0] || inputData.main[0].length !== numberOfItems) {
|
||||||
@@ -1943,7 +1984,8 @@ describe('RoutingNode', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const spy = jest.spyOn(utilsModule, 'sleep').mockReturnValue(
|
const workflowPackage = await import('n8n-workflow');
|
||||||
|
const spy = jest.spyOn(workflowPackage, 'sleep').mockReturnValue(
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
resolve();
|
resolve();
|
||||||
}),
|
}),
|
||||||
@@ -1956,18 +1998,13 @@ describe('RoutingNode', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getNodeParameter = executeSingleFunctions.getNodeParameter;
|
const getNodeParameter = executeSingleFunctions.getNodeParameter;
|
||||||
|
// @ts-expect-error overwriting a method
|
||||||
executeSingleFunctions.getNodeParameter = (parameterName: string) =>
|
executeSingleFunctions.getNodeParameter = (parameterName: string) =>
|
||||||
parameterName in testData.input.node.parameters
|
parameterName in testData.input.node.parameters
|
||||||
? testData.input.node.parameters[parameterName]
|
? testData.input.node.parameters[parameterName]
|
||||||
: (getNodeParameter(parameterName) ?? {});
|
: (getNodeParameter(parameterName) ?? {});
|
||||||
|
|
||||||
const result = await routingNode.runNode(
|
const result = await routingNode.runNode(inputData, runIndex, nodeType, executeData);
|
||||||
inputData,
|
|
||||||
runIndex,
|
|
||||||
nodeType,
|
|
||||||
executeData,
|
|
||||||
nodeExecuteFunctions as INodeExecuteFunctions,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (testData.input.specialTestOptions?.sleepCalls) {
|
if (testData.input.specialTestOptions?.sleepCalls) {
|
||||||
expect(spy.mock.calls).toEqual(testData.input.specialTestOptions?.sleepCalls);
|
expect(spy.mock.calls).toEqual(testData.input.specialTestOptions?.sleepCalls);
|
||||||
@@ -2042,7 +2079,6 @@ describe('RoutingNode', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const nodeTypes = Helpers.NodeTypes();
|
|
||||||
const baseNode: INode = {
|
const baseNode: INode = {
|
||||||
parameters: {},
|
parameters: {},
|
||||||
name: 'test',
|
name: 'test',
|
||||||
@@ -2052,12 +2088,10 @@ describe('RoutingNode', () => {
|
|||||||
position: [0, 0],
|
position: [0, 0],
|
||||||
};
|
};
|
||||||
|
|
||||||
const mode = 'internal';
|
|
||||||
const runIndex = 0;
|
const runIndex = 0;
|
||||||
const itemIndex = 0;
|
const itemIndex = 0;
|
||||||
const connectionInputData: INodeExecutionData[] = [];
|
|
||||||
const runExecutionData: IRunExecutionData = { resultData: { runData: {} } };
|
const runExecutionData: IRunExecutionData = { resultData: { runData: {} } };
|
||||||
const nodeType = nodeTypes.getByNameAndVersion(baseNode.type);
|
const nodeType = mock<INodeType>();
|
||||||
|
|
||||||
const inputData: ITaskDataConnections = {
|
const inputData: ITaskDataConnections = {
|
||||||
main: [
|
main: [
|
||||||
@@ -2093,53 +2127,17 @@ describe('RoutingNode', () => {
|
|||||||
nodeTypes,
|
nodeTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
const routingNode = new RoutingNode(
|
|
||||||
workflow,
|
|
||||||
node,
|
|
||||||
connectionInputData,
|
|
||||||
runExecutionData ?? null,
|
|
||||||
additionalData,
|
|
||||||
mode,
|
|
||||||
);
|
|
||||||
|
|
||||||
const executeData = {
|
|
||||||
data: {},
|
|
||||||
node,
|
|
||||||
source: null,
|
|
||||||
} as IExecuteData;
|
|
||||||
|
|
||||||
let currentItemIndex = 0;
|
let currentItemIndex = 0;
|
||||||
for (let iteration = 0; iteration < inputData.main[0]!.length; iteration++) {
|
for (let iteration = 0; iteration < inputData.main[0]!.length; iteration++) {
|
||||||
const nodeExecuteFunctions: Partial<INodeExecuteFunctions> = {
|
const context = getExecuteSingleFunctions(
|
||||||
getExecuteSingleFunctions: () => {
|
workflow,
|
||||||
return Helpers.getExecuteSingleFunctions(
|
runExecutionData,
|
||||||
workflow,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
node,
|
|
||||||
itemIndex + iteration,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!nodeExecuteFunctions.getExecuteSingleFunctions) {
|
|
||||||
fail('Expected nodeExecuteFunctions to contain getExecuteSingleFunctions');
|
|
||||||
}
|
|
||||||
|
|
||||||
const routingNodeExecutionContext = nodeExecuteFunctions.getExecuteSingleFunctions(
|
|
||||||
routingNode.workflow,
|
|
||||||
routingNode.runExecutionData,
|
|
||||||
runIndex,
|
runIndex,
|
||||||
routingNode.connectionInputData,
|
node,
|
||||||
inputData,
|
itemIndex + iteration,
|
||||||
routingNode.node,
|
|
||||||
iteration,
|
|
||||||
routingNode.additionalData,
|
|
||||||
executeData,
|
|
||||||
routingNode.mode,
|
|
||||||
);
|
);
|
||||||
|
jest.spyOn(executionContexts, 'ExecuteSingleContext').mockReturnValue(context);
|
||||||
currentItemIndex = routingNodeExecutionContext.getItemIndex();
|
currentItemIndex = context.getItemIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedItemIndex = inputData.main[0]!.length - 1;
|
const expectedItemIndex = inputData.main[0]!.length - 1;
|
||||||
157
packages/core/test/TriggersAndPollers.test.ts
Normal file
157
packages/core/test/TriggersAndPollers.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
Workflow,
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
IPollFunctions,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
INodeType,
|
||||||
|
INodeTypes,
|
||||||
|
ITriggerFunctions,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { TriggersAndPollers } from '@/TriggersAndPollers';
|
||||||
|
|
||||||
|
describe('TriggersAndPollers', () => {
|
||||||
|
const node = mock<INode>();
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
trigger: undefined,
|
||||||
|
poll: undefined,
|
||||||
|
});
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const workflow = mock<Workflow>({ nodeTypes });
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||||
|
hooks: {
|
||||||
|
hookFunctions: {
|
||||||
|
sendResponse: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const triggersAndPollers = new TriggersAndPollers();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runTrigger()', () => {
|
||||||
|
const triggerFunctions = mock<ITriggerFunctions>();
|
||||||
|
const getTriggerFunctions = jest.fn().mockReturnValue(triggerFunctions);
|
||||||
|
const triggerFn = jest.fn();
|
||||||
|
|
||||||
|
it('should throw error if node type does not have trigger function', async () => {
|
||||||
|
await expect(
|
||||||
|
triggersAndPollers.runTrigger(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
getTriggerFunctions,
|
||||||
|
additionalData,
|
||||||
|
'trigger',
|
||||||
|
'init',
|
||||||
|
),
|
||||||
|
).rejects.toThrow(ApplicationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call trigger function in regular mode', async () => {
|
||||||
|
nodeType.trigger = triggerFn;
|
||||||
|
triggerFn.mockResolvedValue({ test: true });
|
||||||
|
|
||||||
|
const result = await triggersAndPollers.runTrigger(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
getTriggerFunctions,
|
||||||
|
additionalData,
|
||||||
|
'trigger',
|
||||||
|
'init',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(triggerFn).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ test: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle manual mode with promise resolution', async () => {
|
||||||
|
const mockEmitData: INodeExecutionData[][] = [[{ json: { data: 'test' } }]];
|
||||||
|
const mockTriggerResponse = { workflowId: '123' };
|
||||||
|
|
||||||
|
nodeType.trigger = triggerFn;
|
||||||
|
triggerFn.mockResolvedValue(mockTriggerResponse);
|
||||||
|
|
||||||
|
const result = await triggersAndPollers.runTrigger(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
getTriggerFunctions,
|
||||||
|
additionalData,
|
||||||
|
'manual',
|
||||||
|
'init',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result?.manualTriggerResponse).toBeInstanceOf(Promise);
|
||||||
|
|
||||||
|
// Simulate emit
|
||||||
|
const mockTriggerFunctions = getTriggerFunctions.mock.results[0]?.value;
|
||||||
|
if (mockTriggerFunctions?.emit) {
|
||||||
|
mockTriggerFunctions.emit(mockEmitData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error emission in manual mode', async () => {
|
||||||
|
const testError = new Error('Test error');
|
||||||
|
|
||||||
|
nodeType.trigger = triggerFn;
|
||||||
|
triggerFn.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await triggersAndPollers.runTrigger(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
getTriggerFunctions,
|
||||||
|
additionalData,
|
||||||
|
'manual',
|
||||||
|
'init',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.manualTriggerResponse).toBeInstanceOf(Promise);
|
||||||
|
|
||||||
|
// Simulate error
|
||||||
|
const mockTriggerFunctions = getTriggerFunctions.mock.results[0]?.value;
|
||||||
|
if (mockTriggerFunctions?.emitError) {
|
||||||
|
mockTriggerFunctions.emitError(testError);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(result?.manualTriggerResponse).rejects.toThrow(testError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runPoll()', () => {
|
||||||
|
const pollFunctions = mock<IPollFunctions>();
|
||||||
|
const pollFn = jest.fn();
|
||||||
|
|
||||||
|
it('should throw error if node type does not have poll function', async () => {
|
||||||
|
await expect(triggersAndPollers.runPoll(workflow, node, pollFunctions)).rejects.toThrow(
|
||||||
|
ApplicationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call poll function and return result', async () => {
|
||||||
|
const mockPollResult: INodeExecutionData[][] = [[{ json: { data: 'test' } }]];
|
||||||
|
nodeType.poll = pollFn;
|
||||||
|
pollFn.mockResolvedValue(mockPollResult);
|
||||||
|
|
||||||
|
const result = await triggersAndPollers.runPoll(workflow, node, pollFunctions);
|
||||||
|
|
||||||
|
expect(pollFn).toHaveBeenCalled();
|
||||||
|
expect(result).toBe(mockPollResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if poll function returns no data', async () => {
|
||||||
|
nodeType.poll = pollFn;
|
||||||
|
pollFn.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await triggersAndPollers.runPoll(workflow, node, pollFunctions);
|
||||||
|
|
||||||
|
expect(pollFn).toHaveBeenCalled();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,12 +9,26 @@
|
|||||||
// XX denotes that the node is disabled
|
// XX denotes that the node is disabled
|
||||||
// PD denotes that the node has pinned data
|
// PD denotes that the node has pinned data
|
||||||
|
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
import { pick } from 'lodash';
|
import { pick } from 'lodash';
|
||||||
import type { IPinData, IRun, IRunData, WorkflowTestData } from 'n8n-workflow';
|
import type {
|
||||||
|
IExecuteData,
|
||||||
|
INode,
|
||||||
|
INodeType,
|
||||||
|
INodeTypes,
|
||||||
|
IPinData,
|
||||||
|
IRun,
|
||||||
|
IRunData,
|
||||||
|
IRunExecutionData,
|
||||||
|
ITriggerResponse,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
WorkflowTestData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
ApplicationError,
|
ApplicationError,
|
||||||
createDeferredPromise,
|
createDeferredPromise,
|
||||||
NodeExecutionOutput,
|
NodeExecutionOutput,
|
||||||
|
NodeHelpers,
|
||||||
Workflow,
|
Workflow,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
@@ -444,4 +458,150 @@ describe('WorkflowExecute', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const workflowExecute = new WorkflowExecute(mock(), 'manual');
|
||||||
|
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('should return null if there are no nodes', () => {
|
||||||
|
const workflow = new Workflow({
|
||||||
|
nodes: [],
|
||||||
|
connections: {},
|
||||||
|
active: false,
|
||||||
|
nodeTypes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issues = workflowExecute.checkReadyForExecution(workflow);
|
||||||
|
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 = workflowExecute.checkReadyForExecution(workflow, {
|
||||||
|
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 = workflowExecute.checkReadyForExecution(workflow, {
|
||||||
|
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 = workflowExecute.checkReadyForExecution(workflow, {
|
||||||
|
startNode: startNode.name,
|
||||||
|
});
|
||||||
|
expect(issues).toEqual({ [startNode.name]: { execution: false } });
|
||||||
|
expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledTimes(2);
|
||||||
|
expect(nodeParamIssuesSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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 abortController = new AbortController();
|
||||||
|
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
|
||||||
|
|
||||||
|
test('should call closeFunction when manual trigger is aborted', async () => {
|
||||||
|
const runPromise = workflowExecute.runNode(
|
||||||
|
workflow,
|
||||||
|
executionData,
|
||||||
|
runExecutionData,
|
||||||
|
0,
|
||||||
|
additionalData,
|
||||||
|
'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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,6 +102,89 @@ export const predefinedNodesTypes: INodeTypeData = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'test.set': {
|
||||||
|
sourcePath: '',
|
||||||
|
type: {
|
||||||
|
description: {
|
||||||
|
displayName: 'Set',
|
||||||
|
name: 'set',
|
||||||
|
group: ['input'],
|
||||||
|
version: 1,
|
||||||
|
description: 'Sets a value',
|
||||||
|
defaults: {
|
||||||
|
name: 'Set',
|
||||||
|
color: '#0000FF',
|
||||||
|
},
|
||||||
|
inputs: [NodeConnectionType.Main],
|
||||||
|
outputs: [NodeConnectionType.Main],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
displayName: 'Value1',
|
||||||
|
name: 'value1',
|
||||||
|
type: 'string',
|
||||||
|
default: 'default-value1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Value2',
|
||||||
|
name: 'value2',
|
||||||
|
type: 'string',
|
||||||
|
default: 'default-value2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'test.setMulti': {
|
||||||
|
sourcePath: '',
|
||||||
|
type: {
|
||||||
|
description: {
|
||||||
|
displayName: 'Set Multi',
|
||||||
|
name: 'setMulti',
|
||||||
|
group: ['input'],
|
||||||
|
version: 1,
|
||||||
|
description: 'Sets multiple values',
|
||||||
|
defaults: {
|
||||||
|
name: 'Set Multi',
|
||||||
|
color: '#0000FF',
|
||||||
|
},
|
||||||
|
inputs: [NodeConnectionType.Main],
|
||||||
|
outputs: [NodeConnectionType.Main],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
displayName: 'Values',
|
||||||
|
name: 'values',
|
||||||
|
type: 'fixedCollection',
|
||||||
|
typeOptions: {
|
||||||
|
multipleValues: true,
|
||||||
|
},
|
||||||
|
default: {},
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'string',
|
||||||
|
displayName: 'String',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
displayName: 'Name',
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
default: 'propertyName',
|
||||||
|
placeholder: 'Name of the property to write data to.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Value',
|
||||||
|
name: 'value',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
placeholder: 'The string value to write in the property.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const legacyWorkflowExecuteTests: WorkflowTestData[] = [
|
export const legacyWorkflowExecuteTests: WorkflowTestData[] = [
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { predefinedNodesTypes } from './constants';
|
|||||||
const BASE_DIR = path.resolve(__dirname, '../../..');
|
const BASE_DIR = path.resolve(__dirname, '../../..');
|
||||||
|
|
||||||
class NodeTypesClass implements INodeTypes {
|
class NodeTypesClass implements INodeTypes {
|
||||||
constructor(private nodeTypes: INodeTypeData = predefinedNodesTypes) {}
|
constructor(private nodeTypes: INodeTypeData) {}
|
||||||
|
|
||||||
getByName(nodeType: string): INodeType | IVersionedNodeType {
|
getByName(nodeType: string): INodeType | IVersionedNodeType {
|
||||||
return this.nodeTypes[nodeType].type;
|
return this.nodeTypes[nodeType].type;
|
||||||
@@ -41,7 +41,7 @@ class NodeTypesClass implements INodeTypes {
|
|||||||
|
|
||||||
let nodeTypesInstance: NodeTypesClass | undefined;
|
let nodeTypesInstance: NodeTypesClass | undefined;
|
||||||
|
|
||||||
export function NodeTypes(nodeTypes?: INodeTypeData): INodeTypes {
|
export function NodeTypes(nodeTypes: INodeTypeData = predefinedNodesTypes): INodeTypes {
|
||||||
if (nodeTypesInstance === undefined || nodeTypes !== undefined) {
|
if (nodeTypesInstance === undefined || nodeTypes !== undefined) {
|
||||||
nodeTypesInstance = new NodeTypesClass(nodeTypes);
|
nodeTypesInstance = new NodeTypesClass(nodeTypes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ const executeButtonTooltip = computed(() => {
|
|||||||
node.value &&
|
node.value &&
|
||||||
isLatestNodeVersion.value &&
|
isLatestNodeVersion.value &&
|
||||||
props.inputSize > 1 &&
|
props.inputSize > 1 &&
|
||||||
!NodeHelpers.isSingleExecution(node.value.type, node.value.parameters)
|
!nodeHelpers.isSingleExecution(node.value.type, node.value.parameters)
|
||||||
) {
|
) {
|
||||||
return i18n.baseText('nodeSettings.executeButtonTooltip.times', {
|
return i18n.baseText('nodeSettings.executeButtonTooltip.times', {
|
||||||
interpolate: { inputSize: props.inputSize },
|
interpolate: { inputSize: props.inputSize },
|
||||||
|
|||||||
@@ -234,4 +234,37 @@ describe('useNodeHelpers()', () => {
|
|||||||
expect(webhook.webhookId).toMatch(/\w+(-\w+)+/);
|
expect(webhook.webhookId).toMatch(/\w+(-\w+)+/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isSingleExecution', () => {
|
||||||
|
let isSingleExecution: ReturnType<typeof useNodeHelpers>['isSingleExecution'];
|
||||||
|
beforeEach(() => {
|
||||||
|
isSingleExecution = useNodeHelpers().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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
INodeTypeNameVersion,
|
INodeTypeNameVersion,
|
||||||
IConnection,
|
IConnection,
|
||||||
IPinData,
|
IPinData,
|
||||||
|
NodeParameterValue,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -1268,6 +1269,50 @@ export function useNodeHelpers() {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** nodes that would execute only once with such parameters add 'undefined' to parameters values if it is parameter's default value */
|
||||||
|
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
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasProxyAuth,
|
hasProxyAuth,
|
||||||
isCustomApiCallSelected,
|
isCustomApiCallSelected,
|
||||||
@@ -1305,5 +1350,6 @@ export function useNodeHelpers() {
|
|||||||
getNodeTaskData,
|
getNodeTaskData,
|
||||||
assignNodeId,
|
assignNodeId,
|
||||||
assignWebhookId,
|
assignWebhookId,
|
||||||
|
isSingleExecution,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { NodeParameterValue } from './Interfaces';
|
|
||||||
|
|
||||||
export const DIGITS = '0123456789';
|
export const DIGITS = '0123456789';
|
||||||
export const UPPERCASE_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
export const UPPERCASE_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
export const LOWERCASE_LETTERS = UPPERCASE_LETTERS.toLowerCase();
|
export const LOWERCASE_LETTERS = UPPERCASE_LETTERS.toLowerCase();
|
||||||
@@ -87,35 +85,6 @@ export const LANGCHAIN_CUSTOM_TOOLS = [
|
|||||||
HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE,
|
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 SEND_AND_WAIT_OPERATION = 'sendAndWait';
|
||||||
export const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
|
export const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
|
||||||
export const AI_TRANSFORM_JS_CODE = 'jsCode';
|
export const AI_TRANSFORM_JS_CODE = 'jsCode';
|
||||||
|
|||||||
@@ -421,60 +421,6 @@ export interface IRunNodeResponse {
|
|||||||
data: INodeExecutionData[][] | NodeExecutionOutput | null | undefined;
|
data: INodeExecutionData[][] | NodeExecutionOutput | null | undefined;
|
||||||
closeFunction?: CloseFunction;
|
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 {
|
export interface ISourceDataConnections {
|
||||||
// Key for each input type and because there can be multiple inputs of the same type it is an array
|
// 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 {
|
export interface INodeExecuteFunctions {
|
||||||
getExecutePollFunctions: IGetExecutePollFunctions;
|
getExecutePollFunctions: IGetExecutePollFunctions;
|
||||||
getExecuteTriggerFunctions: IGetExecuteTriggerFunctions;
|
getExecuteTriggerFunctions: IGetExecuteTriggerFunctions;
|
||||||
getExecuteFunctions: IGetExecuteFunctions;
|
|
||||||
getExecuteSingleFunctions: IGetExecuteSingleFunctions;
|
|
||||||
getExecuteHookFunctions: IGetExecuteHookFunctions;
|
|
||||||
getExecuteWebhookFunctions: IGetExecuteWebhookFunctions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NodeParameterValue = string | number | boolean | undefined | null;
|
export type NodeParameterValue = string | number | boolean | undefined | null;
|
||||||
|
|||||||
@@ -6,13 +6,11 @@
|
|||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import isEqual from 'lodash/isEqual';
|
import isEqual from 'lodash/isEqual';
|
||||||
|
|
||||||
import { SINGLE_EXECUTION_NODES } from './Constants';
|
|
||||||
import { ApplicationError } from './errors/application.error';
|
import { ApplicationError } from './errors/application.error';
|
||||||
import { NodeConnectionType } from './Interfaces';
|
import { NodeConnectionType } from './Interfaces';
|
||||||
import type {
|
import type {
|
||||||
FieldType,
|
FieldType,
|
||||||
IContextObject,
|
IContextObject,
|
||||||
IHttpRequestMethods,
|
|
||||||
INode,
|
INode,
|
||||||
INodeCredentialDescription,
|
INodeCredentialDescription,
|
||||||
INodeIssueObjectProperty,
|
INodeIssueObjectProperty,
|
||||||
@@ -29,12 +27,9 @@ import type {
|
|||||||
IParameterDependencies,
|
IParameterDependencies,
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
IVersionedNodeType,
|
IVersionedNodeType,
|
||||||
IWebhookData,
|
|
||||||
IWorkflowExecuteAdditionalData,
|
|
||||||
NodeParameterValue,
|
NodeParameterValue,
|
||||||
ResourceMapperValue,
|
ResourceMapperValue,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
INodeTypeBaseDescription,
|
|
||||||
INodeOutputConfiguration,
|
INodeOutputConfiguration,
|
||||||
INodeInputConfiguration,
|
INodeInputConfiguration,
|
||||||
GenericValue,
|
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 = {
|
const declarativeNodeOptionParameters: INodeProperties = {
|
||||||
displayName: 'Request Options',
|
displayName: 'Request Options',
|
||||||
name: 'requestOptions',
|
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.
|
* 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.
|
* @param typeDescription The node's type description to check.
|
||||||
@@ -514,27 +387,6 @@ export function applyDeclarativeNodeOptionParameters(nodeType: INodeType): void
|
|||||||
return;
|
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 = (
|
const getPropertyValues = (
|
||||||
nodeValues: INodeParameters,
|
nodeValues: INodeParameters,
|
||||||
propertyName: string,
|
propertyName: string,
|
||||||
@@ -747,7 +599,6 @@ export function getContext(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns which parameters are dependent on which
|
* Returns which parameters are dependent on which
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
function getParameterDependencies(nodePropertiesArray: INodeProperties[]): IParameterDependencies {
|
function getParameterDependencies(nodePropertiesArray: INodeProperties[]): IParameterDependencies {
|
||||||
const dependencies: IParameterDependencies = {};
|
const dependencies: IParameterDependencies = {};
|
||||||
@@ -783,7 +634,6 @@ function getParameterDependencies(nodePropertiesArray: INodeProperties[]): IPara
|
|||||||
/**
|
/**
|
||||||
* Returns in which order the parameters should be resolved
|
* Returns in which order the parameters should be resolved
|
||||||
* to have the parameters available they depend on
|
* to have the parameters available they depend on
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
export function getParameterResolveOrder(
|
export function getParameterResolveOrder(
|
||||||
nodePropertiesArray: INodeProperties[],
|
nodePropertiesArray: INodeProperties[],
|
||||||
@@ -1177,121 +1027,8 @@ export function getNodeParameters(
|
|||||||
return nodeParameters;
|
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
|
* Returns the webhook path
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
export function getNodeWebhookPath(
|
export function getNodeWebhookPath(
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
@@ -1317,7 +1054,6 @@ export function getNodeWebhookPath(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the webhook URL
|
* Returns the webhook URL
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
export function getNodeWebhookUrl(
|
export function getNodeWebhookUrl(
|
||||||
baseUrl: string,
|
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
|
* Validates resource locator node parameters based on validation ruled defined in each parameter mode
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
export const validateResourceLocatorParameter = (
|
const validateResourceLocatorParameter = (
|
||||||
value: INodeParameterResourceLocator,
|
value: INodeParameterResourceLocator,
|
||||||
parameterMode: INodePropertyMode,
|
parameterMode: INodePropertyMode,
|
||||||
): string[] => {
|
): string[] => {
|
||||||
@@ -1592,9 +1327,8 @@ export const validateResourceLocatorParameter = (
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Validates resource mapper values based on service schema
|
* Validates resource mapper values based on service schema
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
export const validateResourceMapperParameter = (
|
const validateResourceMapperParameter = (
|
||||||
nodeProperties: INodeProperties,
|
nodeProperties: INodeProperties,
|
||||||
value: ResourceMapperValue,
|
value: ResourceMapperValue,
|
||||||
skipRequiredCheck = false,
|
skipRequiredCheck = false,
|
||||||
@@ -1633,7 +1367,7 @@ export const validateResourceMapperParameter = (
|
|||||||
return issues;
|
return issues;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateParameter = (
|
const validateParameter = (
|
||||||
nodeProperties: INodeProperties,
|
nodeProperties: INodeProperties,
|
||||||
value: GenericValue,
|
value: GenericValue,
|
||||||
type: FieldType,
|
type: FieldType,
|
||||||
@@ -1661,7 +1395,7 @@ export const validateParameter = (
|
|||||||
* @param {INodeProperties} nodeProperties The properties of the node
|
* @param {INodeProperties} nodeProperties The properties of the node
|
||||||
* @param {NodeParameterValue} value The value of the parameter
|
* @param {NodeParameterValue} value The value of the parameter
|
||||||
*/
|
*/
|
||||||
export function addToIssuesIfMissing(
|
function addToIssuesIfMissing(
|
||||||
foundIssues: INodeIssues,
|
foundIssues: INodeIssues,
|
||||||
nodeProperties: INodeProperties,
|
nodeProperties: INodeProperties,
|
||||||
value: NodeParameterValue | INodeParameterResourceLocator,
|
value: NodeParameterValue | INodeParameterResourceLocator,
|
||||||
@@ -1936,7 +1670,6 @@ export function mergeIssues(destination: INodeIssues, source: INodeIssues | null
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges the given node properties
|
* Merges the given node properties
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
export function mergeNodeProperties(
|
export function mergeNodeProperties(
|
||||||
mainProperties: INodeProperties[],
|
mainProperties: INodeProperties[],
|
||||||
@@ -1967,19 +1700,3 @@ export function getVersionedNodeType(
|
|||||||
}
|
}
|
||||||
return object;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,62 +1,36 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-use-before-define */
|
/* eslint-disable @typescript-eslint/no-use-before-define */
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||||
/* eslint-disable @typescript-eslint/no-for-in-array */
|
/* eslint-disable @typescript-eslint/no-for-in-array */
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MANUAL_CHAT_TRIGGER_LANGCHAIN_NODE_TYPE,
|
MANUAL_CHAT_TRIGGER_LANGCHAIN_NODE_TYPE,
|
||||||
NODES_WITH_RENAMABLE_CONTENT,
|
NODES_WITH_RENAMABLE_CONTENT,
|
||||||
STARTING_NODE_TYPES,
|
STARTING_NODE_TYPES,
|
||||||
} from './Constants';
|
} from './Constants';
|
||||||
import type { IDeferredPromise } from './DeferredPromise';
|
|
||||||
import { ApplicationError } from './errors/application.error';
|
import { ApplicationError } from './errors/application.error';
|
||||||
import { Expression } from './Expression';
|
import { Expression } from './Expression';
|
||||||
import { getGlobalState } from './GlobalState';
|
import { getGlobalState } from './GlobalState';
|
||||||
import type {
|
import type {
|
||||||
IConnections,
|
IConnections,
|
||||||
IExecuteResponsePromiseData,
|
|
||||||
IGetExecuteTriggerFunctions,
|
|
||||||
INode,
|
INode,
|
||||||
INodeExecuteFunctions,
|
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
INodeIssues,
|
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodes,
|
INodes,
|
||||||
INodeType,
|
INodeType,
|
||||||
INodeTypes,
|
INodeTypes,
|
||||||
IPinData,
|
IPinData,
|
||||||
IPollFunctions,
|
|
||||||
IRunExecutionData,
|
|
||||||
ITaskDataConnections,
|
|
||||||
ITriggerResponse,
|
|
||||||
IWebhookData,
|
|
||||||
IWebhookResponseData,
|
|
||||||
IWorkflowIssues,
|
|
||||||
IWorkflowExecuteAdditionalData,
|
|
||||||
IWorkflowSettings,
|
IWorkflowSettings,
|
||||||
WebhookSetupMethodNames,
|
|
||||||
WorkflowActivateMode,
|
|
||||||
WorkflowExecuteMode,
|
|
||||||
IConnection,
|
IConnection,
|
||||||
IConnectedNode,
|
IConnectedNode,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
IExecuteData,
|
|
||||||
INodeConnection,
|
INodeConnection,
|
||||||
IObservableObject,
|
IObservableObject,
|
||||||
IRun,
|
|
||||||
IRunNodeResponse,
|
|
||||||
NodeParameterValueType,
|
NodeParameterValueType,
|
||||||
CloseFunction,
|
|
||||||
INodeOutputConfiguration,
|
INodeOutputConfiguration,
|
||||||
} from './Interfaces';
|
} from './Interfaces';
|
||||||
import { Node, NodeConnectionType } from './Interfaces';
|
import { NodeConnectionType } from './Interfaces';
|
||||||
import * as NodeHelpers from './NodeHelpers';
|
import * as NodeHelpers from './NodeHelpers';
|
||||||
import * as ObservableObject from './ObservableObject';
|
import * as ObservableObject from './ObservableObject';
|
||||||
import { RoutingNode } from './RoutingNode';
|
|
||||||
|
|
||||||
function dedupe<T>(arr: T[]): T[] {
|
function dedupe<T>(arr: T[]): T[] {
|
||||||
return [...new Set(arr)];
|
return [...new Set(arr)];
|
||||||
@@ -214,112 +188,6 @@ export class Workflow {
|
|||||||
return returnConnection;
|
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.
|
* Returns the static data of the workflow.
|
||||||
* It gets saved with the workflow and will be the same for
|
* 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));
|
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) {
|
function hasDotNotationBannedChar(nodeName: string) {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export * from './MessageEventBus';
|
|||||||
export * from './ExecutionStatus';
|
export * from './ExecutionStatus';
|
||||||
export * from './Expression';
|
export * from './Expression';
|
||||||
export * from './NodeHelpers';
|
export * from './NodeHelpers';
|
||||||
export * from './RoutingNode';
|
|
||||||
export * from './Workflow';
|
export * from './Workflow';
|
||||||
export * from './WorkflowDataProxy';
|
export * from './WorkflowDataProxy';
|
||||||
export * from './WorkflowDataProxyEnvProvider';
|
export * from './WorkflowDataProxyEnvProvider';
|
||||||
|
|||||||
@@ -1,70 +1,16 @@
|
|||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { mock } from 'jest-mock-extended';
|
|
||||||
import get from 'lodash/get';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import type {
|
import type { INodeTypes } from '@/Interfaces';
|
||||||
IExecuteSingleFunctions,
|
|
||||||
IHttpRequestOptions,
|
|
||||||
IN8nHttpFullResponse,
|
|
||||||
IN8nHttpResponse,
|
|
||||||
INode,
|
|
||||||
INodeTypes,
|
|
||||||
IRunExecutionData,
|
|
||||||
} from '@/Interfaces';
|
|
||||||
import type { Workflow } from '@/Workflow';
|
|
||||||
|
|
||||||
import { NodeTypes as NodeTypesClass } from './NodeTypes';
|
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;
|
let nodeTypesInstance: NodeTypesClass | undefined;
|
||||||
|
|
||||||
export function NodeTypes(): INodeTypes {
|
export function NodeTypes(): INodeTypes {
|
||||||
if (nodeTypesInstance === undefined) {
|
if (nodeTypesInstance === undefined) {
|
||||||
nodeTypesInstance = new NodeTypesClass();
|
nodeTypesInstance = new NodeTypesClass();
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodeTypesInstance;
|
return nodeTypesInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
getNodeParameters,
|
getNodeParameters,
|
||||||
getNodeHints,
|
getNodeHints,
|
||||||
isSingleExecution,
|
|
||||||
isSubNodeType,
|
isSubNodeType,
|
||||||
applyDeclarativeNodeOptionParameters,
|
applyDeclarativeNodeOptionParameters,
|
||||||
convertNodeToAiTool,
|
|
||||||
} from '@/NodeHelpers';
|
} from '@/NodeHelpers';
|
||||||
import type { Workflow } from '@/Workflow';
|
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', () => {
|
describe('isSubNodeType', () => {
|
||||||
const tests: Array<[boolean, Pick<INodeTypeDescription, 'outputs'> | null]> = [
|
const tests: Array<[boolean, Pick<INodeTypeDescription, 'outputs'> | null]> = [
|
||||||
[false, null],
|
[false, null],
|
||||||
@@ -3637,177 +3607,4 @@ describe('NodeHelpers', () => {
|
|||||||
expect(nodeType.description.properties).toEqual([]);
|
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,22 +6,14 @@ import type {
|
|||||||
IBinaryKeyData,
|
IBinaryKeyData,
|
||||||
IConnections,
|
IConnections,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
IExecuteData,
|
|
||||||
INode,
|
INode,
|
||||||
INodeExecuteFunctions,
|
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodeType,
|
|
||||||
INodeTypeDescription,
|
|
||||||
INodeTypes,
|
INodeTypes,
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
ITriggerFunctions,
|
|
||||||
ITriggerResponse,
|
|
||||||
IWorkflowExecuteAdditionalData,
|
|
||||||
NodeParameterValueType,
|
NodeParameterValueType,
|
||||||
} from '@/Interfaces';
|
} from '@/Interfaces';
|
||||||
import * as NodeHelpers from '@/NodeHelpers';
|
import { Workflow } from '@/Workflow';
|
||||||
import { Workflow, type WorkflowParameters } from '@/Workflow';
|
|
||||||
|
|
||||||
process.env.TEST_VARIABLE_1 = 'valueEnvVariable1';
|
process.env.TEST_VARIABLE_1 = 'valueEnvVariable1';
|
||||||
|
|
||||||
@@ -33,126 +25,6 @@ interface StubNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Workflow', () => {
|
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('renameNodeInParameterValue', () => {
|
||||||
describe('for expressions', () => {
|
describe('for expressions', () => {
|
||||||
const tests = [
|
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', () => {
|
describe('__getConnectionsByDestination', () => {
|
||||||
it('should return empty object when there are no connections', () => {
|
it('should return empty object when there are no connections', () => {
|
||||||
const workflow = new Workflow({
|
const workflow = new Workflow({
|
||||||
|
|||||||
Reference in New Issue
Block a user