refactor(core): Fix lints, improve comment and add initial blackbox tests for the engine (#18748)

This commit is contained in:
Danny Martini
2025-08-26 10:55:43 +02:00
committed by GitHub
parent 8defb2b17c
commit 9bc4f07b79
4 changed files with 229 additions and 11 deletions

View File

@@ -0,0 +1,71 @@
import type { IExecuteFunctions, INodeExecutionData, INodeType } from 'n8n-workflow';
import { NodeTypes } from '@test/helpers';
export const passThroughNode: INodeType = {
description: {
displayName: 'Test Node',
name: 'testNode',
group: ['transform'],
version: 1,
description: 'A minimal node for testing',
defaults: {
name: 'Test Node',
},
inputs: ['main'],
outputs: ['main'],
properties: [],
},
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
return await Promise.resolve([items]);
},
};
export const testNodeWithRequiredProperty: INodeType = {
description: {
displayName: 'Test Node with Required Property',
name: 'testNodeWithRequiredProperty',
group: ['transform'],
version: 1,
description: 'A node for testing with required property',
defaults: {
name: 'Test Node with Required Property',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Required Text',
name: 'requiredText',
type: 'string',
default: '',
placeholder: 'Enter some text',
description: 'A required text input',
required: true,
},
],
},
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
return await Promise.resolve([items]);
},
};
const nodeTypeArguments = {
passThrough: {
type: passThroughNode,
sourcePath: '',
},
testNodeWithRequiredProperty: {
type: testNodeWithRequiredProperty,
sourcePath: '',
},
};
export const nodeTypes = NodeTypes(nodeTypeArguments);
export const types: Record<keyof typeof nodeTypeArguments, string> = {
passThrough: 'passThrough',
testNodeWithRequiredProperty: 'testNodeWithRequiredProperty',
};

View File

@@ -0,0 +1,145 @@
import { mock } from 'jest-mock-extended';
import type {
IRunExecutionData,
IWorkflowExecuteAdditionalData,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import { DirectedGraph } from '../partial-execution-utils';
import { createNodeData } from '../partial-execution-utils/__tests__/helpers';
import { WorkflowExecute } from '../workflow-execute';
import { types, nodeTypes } from './mock-node-types';
describe('processRunExecutionData', () => {
const runHook = jest.fn().mockResolvedValue(undefined);
const additionalData = mock<IWorkflowExecuteAdditionalData>({
hooks: { runHook },
restartExecutionId: undefined,
});
const executionMode: WorkflowExecuteMode = 'trigger';
beforeEach(() => {
jest.resetAllMocks();
});
test('throws if execution-data is missing', () => {
// ARRANGE
const node = createNodeData({ name: 'passThrough', type: types.passThrough });
const workflow = new DirectedGraph()
.addNodes(node)
.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });
const executionData: IRunExecutionData = {
startData: { startNodes: [{ name: node.name, sourceData: null }] },
resultData: { runData: {} },
};
const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);
// ACT & ASSERT
// The function returns a Promise, but throws synchronously, so we can't await it.
// eslint-disable-next-line @typescript-eslint/promise-function-async
expect(() => workflowExecute.processRunExecutionData(workflow)).toThrowError(
new ApplicationError('Failed to run workflow due to missing execution data'),
);
});
test('throws if workflow contains nodes with missing required properties', () => {
// ARRANGE
const node = createNodeData({ name: 'node', type: types.testNodeWithRequiredProperty });
const workflow = new DirectedGraph()
.addNodes(node)
.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });
const taskDataConnection = { main: [[{ json: { foo: 1 } }]] };
const executionData: IRunExecutionData = {
startData: { startNodes: [{ name: node.name, sourceData: null }] },
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [{ data: taskDataConnection, node, source: null }],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);
// ACT & ASSERT
// The function returns a Promise, but throws synchronously, so we can't await it.
// eslint-disable-next-line @typescript-eslint/promise-function-async
expect(() => workflowExecute.processRunExecutionData(workflow)).toThrowError(
new ApplicationError(
'The workflow has issues and cannot be executed for that reason. Please fix them first.',
),
);
});
test('returns input data verbatim', async () => {
// ARRANGE
const node = createNodeData({ name: 'node', type: types.passThrough });
const workflow = new DirectedGraph()
.addNodes(node)
.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });
const taskDataConnection = { main: [[{ json: { foo: 1 } }]] };
const executionData: IRunExecutionData = {
startData: { startNodes: [{ name: node.name, sourceData: null }] },
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [{ data: taskDataConnection, node, source: null }],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);
// ACT
const result = await workflowExecute.processRunExecutionData(workflow);
// ASSERT
expect(result.data.resultData.runData).toMatchObject({ node: [{ data: taskDataConnection }] });
});
test('calls the right hooks in the right order', async () => {
// ARRANGE
const node1 = createNodeData({ name: 'node1', type: types.passThrough });
const node2 = createNodeData({ name: 'node2', type: types.passThrough });
const workflow = new DirectedGraph()
.addNodes(node1, node2)
.addConnections({ from: node1, to: node2 })
.toWorkflow({ name: '', active: false, nodeTypes, settings: { executionOrder: 'v1' } });
const taskDataConnection = { main: [[{ json: { foo: 1 } }]] };
const executionData: IRunExecutionData = {
startData: { startNodes: [{ name: node1.name, sourceData: null }] },
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [{ data: taskDataConnection, node: node1, source: null }],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
};
const workflowExecute = new WorkflowExecute(additionalData, executionMode, executionData);
// ACT
await workflowExecute.processRunExecutionData(workflow);
// ASSERT
expect(runHook).toHaveBeenCalledTimes(6);
expect(runHook).toHaveBeenNthCalledWith(1, 'workflowExecuteBefore', expect.any(Array));
expect(runHook).toHaveBeenNthCalledWith(2, 'nodeExecuteBefore', expect.any(Array));
expect(runHook).toHaveBeenNthCalledWith(3, 'nodeExecuteAfter', expect.any(Array));
expect(runHook).toHaveBeenNthCalledWith(4, 'nodeExecuteBefore', expect.any(Array));
expect(runHook).toHaveBeenNthCalledWith(5, 'nodeExecuteAfter', expect.any(Array));
expect(runHook).toHaveBeenNthCalledWith(6, 'workflowExecuteAfter', expect.any(Array));
});
});

View File

@@ -768,7 +768,7 @@ export class WorkflowExecute {
// eslint-disable-next-line @typescript-eslint/no-for-in-array // eslint-disable-next-line @typescript-eslint/no-for-in-array
for (const outputIndexParent in workflow.connectionsBySourceNode[parentNodeName].main) { for (const outputIndexParent in workflow.connectionsBySourceNode[parentNodeName].main) {
if ( if (
!workflow.connectionsBySourceNode[parentNodeName].main.hasOwnProperty(outputIndexParent) !Object.hasOwn(workflow.connectionsBySourceNode[parentNodeName].main, outputIndexParent)
) { ) {
continue; continue;
} }
@@ -1091,7 +1091,7 @@ export class WorkflowExecute {
* Handles execution of disabled nodes by passing through input data * Handles execution of disabled nodes by passing through input data
*/ */
private handleDisabledNode(inputData: ITaskDataConnections): IRunNodeResponse { private handleDisabledNode(inputData: ITaskDataConnections): IRunNodeResponse {
if (inputData.hasOwnProperty('main') && inputData.main.length > 0) { if (Object.hasOwn(inputData, 'main') && inputData.main.length > 0) {
// If the node is disabled simply return the data from the first main input // If the node is disabled simply return the data from the first main input
if (inputData.main[0] === null) { if (inputData.main[0] === null) {
return { data: undefined }; return { data: undefined };
@@ -1649,7 +1649,7 @@ export class WorkflowExecute {
// Get the index of the current run // Get the index of the current run
runIndex = 0; runIndex = 0;
if (this.runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) { if (Object.hasOwn(this.runExecutionData.resultData.runData, executionNode.name)) {
runIndex = this.runExecutionData.resultData.runData[executionNode.name].length; runIndex = this.runExecutionData.resultData.runData[executionNode.name].length;
} }
currentExecutionTry = `${executionNode.name}:${runIndex}`; currentExecutionTry = `${executionNode.name}:${runIndex}`;
@@ -1849,7 +1849,7 @@ export class WorkflowExecute {
// Add the data to return to the user // Add the data to return to the user
// (currently does not get cloned as data does not get changed, maybe later we should do that?!?!) // (currently does not get cloned as data does not get changed, maybe later we should do that?!?!)
if (!this.runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) { if (!Object.hasOwn(this.runExecutionData.resultData.runData, executionNode.name)) {
this.runExecutionData.resultData.runData[executionNode.name] = []; this.runExecutionData.resultData.runData[executionNode.name] = [];
} }
@@ -1885,7 +1885,7 @@ export class WorkflowExecute {
) )
) { ) {
// Workflow should continue running even if node errors // Workflow should continue running even if node errors
if (executionData.data.hasOwnProperty('main') && executionData.data.main.length > 0) { if (Object.hasOwn(executionData.data, 'main') && executionData.data.main.length > 0) {
// Simply get the input data of the node if it has any and pass it through // Simply get the input data of the node if it has any and pass it through
// to the next node // to the next node
if (executionData.data.main[0] !== null) { if (executionData.data.main[0] !== null) {
@@ -1977,8 +1977,8 @@ export class WorkflowExecute {
// Add the nodes to which the current node has an output connection to that they can // Add the nodes to which the current node has an output connection to that they can
// be executed next // be executed next
if (workflow.connectionsBySourceNode.hasOwnProperty(executionNode.name)) { if (Object.hasOwn(workflow.connectionsBySourceNode, executionNode.name)) {
if (workflow.connectionsBySourceNode[executionNode.name].hasOwnProperty('main')) { if (Object.hasOwn(workflow.connectionsBySourceNode[executionNode.name], 'main')) {
let outputIndex: string; let outputIndex: string;
let connectionData: IConnection; let connectionData: IConnection;
// Iterate over all the outputs // Iterate over all the outputs
@@ -1993,7 +1993,8 @@ export class WorkflowExecute {
// eslint-disable-next-line @typescript-eslint/no-for-in-array // eslint-disable-next-line @typescript-eslint/no-for-in-array
for (outputIndex in workflow.connectionsBySourceNode[executionNode.name].main) { for (outputIndex in workflow.connectionsBySourceNode[executionNode.name].main) {
if ( if (
!workflow.connectionsBySourceNode[executionNode.name].main.hasOwnProperty( !Object.hasOwn(
workflow.connectionsBySourceNode[executionNode.name].main,
outputIndex, outputIndex,
) )
) { ) {
@@ -2004,7 +2005,7 @@ export class WorkflowExecute {
for (connectionData of workflow.connectionsBySourceNode[executionNode.name].main[ for (connectionData of workflow.connectionsBySourceNode[executionNode.name].main[
outputIndex outputIndex
] ?? []) { ] ?? []) {
if (!workflow.nodes.hasOwnProperty(connectionData.node)) { if (!Object.hasOwn(workflow.nodes, connectionData.node)) {
throw new ApplicationError('Destination node not found', { throw new ApplicationError('Destination node not found', {
extra: { extra: {
sourceNodeName: executionNode.name, sourceNodeName: executionNode.name,
@@ -2324,7 +2325,7 @@ export class WorkflowExecute {
return true; return true;
} }
if (!executionData.data.hasOwnProperty('main')) { if (!Object.hasOwn(executionData.data, 'main')) {
// ExecutionData does not even have the connection set up so can // ExecutionData does not even have the connection set up so can
// not have that data, so add it again to be executed later // not have that data, so add it again to be executed later
this.runExecutionData.executionData!.nodeExecutionStack.push(executionData); this.runExecutionData.executionData!.nodeExecutionStack.push(executionData);
@@ -2595,7 +2596,7 @@ export class WorkflowExecute {
item: 0, item: 0,
}; };
} else if (isSameNumberOfItems) { } else if (isSameNumberOfItems) {
// The number of oncoming and outcoming items is identical so we can // The number of incoming and outgoing items is identical so we can
// make the reasonable assumption that each of the input items // make the reasonable assumption that each of the input items
// is the origin of the corresponding output items // is the origin of the corresponding output items
item.pairedItem = { item.pairedItem = {

View File

@@ -4,6 +4,7 @@
"@n8n/typescript-config/tsconfig.backend.json" "@n8n/typescript-config/tsconfig.backend.json"
], ],
"compilerOptions": { "compilerOptions": {
"lib": ["es2022"],
"rootDir": ".", "rootDir": ".",
"baseUrl": "src", "baseUrl": "src",
"paths": { "paths": {