refactor(core): Move execution engine code out of n8n-workflow (no-changelog) (#12147)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2024-12-12 13:54:44 +01:00
committed by GitHub
parent 73f0c4cca9
commit 5a055ed526
44 changed files with 1995 additions and 1795 deletions

View File

@@ -675,7 +675,6 @@ describe('NodeExecuteFunctions', () => {
beforeEach(() => {
nock.cleanAll();
nock.disableNetConnect();
jest.clearAllMocks();
});

File diff suppressed because it is too large Load Diff

View 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();
});
});
});

View File

@@ -9,12 +9,26 @@
// XX denotes that the node is disabled
// PD denotes that the node has pinned data
import { mock } from 'jest-mock-extended';
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 {
ApplicationError,
createDeferredPromise,
NodeExecutionOutput,
NodeHelpers,
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();
});
});
});

View File

@@ -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[] = [

View File

@@ -24,7 +24,7 @@ import { predefinedNodesTypes } from './constants';
const BASE_DIR = path.resolve(__dirname, '../../..');
class NodeTypesClass implements INodeTypes {
constructor(private nodeTypes: INodeTypeData = predefinedNodesTypes) {}
constructor(private nodeTypes: INodeTypeData) {}
getByName(nodeType: string): INodeType | IVersionedNodeType {
return this.nodeTypes[nodeType].type;
@@ -41,7 +41,7 @@ class NodeTypesClass implements INodeTypes {
let nodeTypesInstance: NodeTypesClass | undefined;
export function NodeTypes(nodeTypes?: INodeTypeData): INodeTypes {
export function NodeTypes(nodeTypes: INodeTypeData = predefinedNodesTypes): INodeTypes {
if (nodeTypesInstance === undefined || nodeTypes !== undefined) {
nodeTypesInstance = new NodeTypesClass(nodeTypes);
}