import { Logger } from '@n8n/backend-common'; import type { Project } from '@n8n/db'; import { ExecutionRepository } from '@n8n/db'; import { stringify } from 'flatted'; import { mock } from 'jest-mock-extended'; import { BinaryDataService, ErrorReporter, InstanceSettings, ExecutionLifecycleHooks, BinaryDataConfig, } from 'n8n-core'; import { ExpressionError } from 'n8n-workflow'; import type { IRunExecutionData, ITaskData, Workflow, IDataObject, IRun, INode, IWorkflowBase, WorkflowExecuteMode, ITaskStartedData, } from 'n8n-workflow'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { Push } from '@/push'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; import { mockInstance } from '@test/mocking'; import { getLifecycleHooksForSubExecutions, getLifecycleHooksForRegularMain, getLifecycleHooksForScalingWorker, getLifecycleHooksForScalingMain, } from '../execution-lifecycle-hooks'; describe('Execution Lifecycle Hooks', () => { mockInstance(Logger); mockInstance(InstanceSettings); const errorReporter = mockInstance(ErrorReporter); const eventService = mockInstance(EventService); const executionRepository = mockInstance(ExecutionRepository); const externalHooks = mockInstance(ExternalHooks); const push = mockInstance(Push); const workflowStaticDataService = mockInstance(WorkflowStaticDataService); const workflowStatisticsService = mockInstance(WorkflowStatisticsService); const binaryDataService = mockInstance(BinaryDataService); const ownershipService = mockInstance(OwnershipService); const workflowExecutionService = mockInstance(WorkflowExecutionService); const nodeName = 'Test Node'; const node = mock(); const workflowId = 'test-workflow-id'; const executionId = 'test-execution-id'; const workflowData: IWorkflowBase = { id: workflowId, name: 'Test Workflow', active: true, isArchived: false, connections: {}, nodes: [], settings: {}, createdAt: new Date(), updatedAt: new Date(), }; const workflow = mock(); const staticData = mock(); const taskStartedData = mock(); const taskData = mock(); const runExecutionData = mock(); const successfulRun = mock({ status: 'success', finished: true, waitTill: undefined, }); const failedRun = mock({ status: 'error', finished: true, waitTill: undefined, }); const waitingRun = mock({ finished: true, status: 'waiting', waitTill: new Date(), }); const expressionError = new ExpressionError('Error'); const pushRef = 'test-push-ref'; const retryOf = 'test-retry-of'; const userId = 'test-user-id'; const now = new Date('2025-01-13T18:25:50.267Z'); jest.useFakeTimers({ now }); let lifecycleHooks: ExecutionLifecycleHooks; beforeEach(() => { jest.clearAllMocks(); workflowData.settings = {}; successfulRun.data = { resultData: { runData: {}, }, }; failedRun.data = { resultData: { runData: {}, error: expressionError, }, }; }); const workflowEventTests = (expectedUserId?: string) => { describe('workflowExecuteBefore', () => { it('should emit workflow-pre-execute events', async () => { await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); expect(eventService.emit).toHaveBeenCalledWith('workflow-pre-execute', { executionId, data: workflowData, }); }); }); describe('workflowExecuteAfter', () => { it('should emit workflow-post-execute events', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); expect(eventService.emit).toHaveBeenCalledWith('workflow-post-execute', { executionId, runData: successfulRun, workflow: workflowData, userId: expectedUserId, }); }); it('should not emit workflow-post-execute events for waiting executions', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [waitingRun, {}]); expect(eventService.emit).not.toHaveBeenCalledWith('workflow-post-execute'); }); }); }; const nodeEventsTests = () => { describe('nodeExecuteBefore', () => { it('should emit node-pre-execute event', async () => { await lifecycleHooks.runHook('nodeExecuteBefore', [nodeName, taskStartedData]); expect(eventService.emit).toHaveBeenCalledWith('node-pre-execute', { executionId, workflow: workflowData, nodeName, }); }); }); describe('nodeExecuteAfter', () => { it('should emit node-post-execute event', async () => { await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]); expect(eventService.emit).toHaveBeenCalledWith('node-post-execute', { executionId, workflow: workflowData, nodeName, }); }); }); }; const externalHooksTests = () => { describe('workflowExecuteBefore', () => { it('should run workflow.preExecute hook', async () => { await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); expect(externalHooks.run).toHaveBeenCalledWith('workflow.preExecute', [workflow, 'manual']); }); }); describe('workflowExecuteAfter', () => { it('should run workflow.postExecute hook', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); expect(externalHooks.run).toHaveBeenCalledWith('workflow.postExecute', [ successfulRun, workflowData, executionId, ]); }); }); }; const statisticsTests = () => { describe('statistics events', () => { it('workflowExecuteAfter should emit workflowExecutionCompleted statistics event', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); expect(workflowStatisticsService.emit).toHaveBeenCalledWith('workflowExecutionCompleted', { workflowData, fullRunData: successfulRun, }); }); it('nodeFetchedData should handle nodeFetchedData statistics event', async () => { await lifecycleHooks.runHook('nodeFetchedData', [workflowId, node]); expect(workflowStatisticsService.emit).toHaveBeenCalledWith('nodeFetchedData', { workflowId, node, }); }); }); }; describe('getLifecycleHooksForRegularMain', () => { const createHooks = (executionMode: WorkflowExecuteMode = 'manual') => getLifecycleHooksForRegularMain( { executionMode, workflowData, pushRef, retryOf, userId }, executionId, ); beforeEach(() => { lifecycleHooks = createHooks(); }); workflowEventTests(userId); nodeEventsTests(); externalHooksTests(); statisticsTests(); it('should setup the correct set of hooks', () => { expect(lifecycleHooks).toBeInstanceOf(ExecutionLifecycleHooks); expect(lifecycleHooks.mode).toBe('manual'); expect(lifecycleHooks.executionId).toBe(executionId); expect(lifecycleHooks.workflowData).toEqual(workflowData); const { handlers } = lifecycleHooks; expect(handlers.nodeExecuteBefore).toHaveLength(2); expect(handlers.nodeExecuteAfter).toHaveLength(2); expect(handlers.workflowExecuteBefore).toHaveLength(3); expect(handlers.workflowExecuteAfter).toHaveLength(5); expect(handlers.nodeFetchedData).toHaveLength(1); expect(handlers.sendResponse).toHaveLength(0); }); describe('nodeExecuteBefore', () => { it('should send nodeExecuteBefore push event', async () => { await lifecycleHooks.runHook('nodeExecuteBefore', [nodeName, taskStartedData]); expect(push.send).toHaveBeenCalledWith( { type: 'nodeExecuteBefore', data: { executionId, nodeName, data: taskStartedData } }, pushRef, ); }); }); describe('nodeExecuteAfter', () => { it('should send nodeExecuteAfter push event', async () => { await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]); expect(push.send).toHaveBeenCalledWith( { type: 'nodeExecuteAfter', data: { executionId, nodeName, data: taskData } }, pushRef, ); }); it('should save execution progress when enabled', async () => { workflowData.settings = { saveExecutionProgress: true }; lifecycleHooks = createHooks(); expect(lifecycleHooks.handlers.nodeExecuteAfter).toHaveLength(3); await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]); expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(executionId, { includeData: true, unflattenData: true, }); }); it('should not save execution progress when disabled', async () => { workflowData.settings = { saveExecutionProgress: false }; lifecycleHooks = createHooks(); expect(lifecycleHooks.handlers.nodeExecuteAfter).toHaveLength(2); await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]); expect(executionRepository.findSingleExecution).not.toHaveBeenCalled(); }); }); describe('workflowExecuteBefore', () => { it('should send executionStarted push event', async () => { await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); expect(push.send).toHaveBeenCalledWith( { type: 'executionStarted', data: { executionId, mode: 'manual', retryOf, workflowId: 'test-workflow-id', workflowName: 'Test Workflow', startedAt: now, flattedRunData: '[{}]', }, }, pushRef, ); }); it('should run workflow.preExecute external hook', async () => { await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); expect(externalHooks.run).toHaveBeenCalledWith('workflow.preExecute', [workflow, 'manual']); }); }); describe('workflowExecuteAfter', () => { it('should send executionFinished push event', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); expect(push.send).toHaveBeenCalledWith( { type: 'executionFinished', data: { executionId, rawData: stringify(successfulRun.data), status: 'success', workflowId: 'test-workflow-id', }, }, pushRef, ); }); it('should send executionWaiting push event', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [waitingRun, {}]); expect(push.send).toHaveBeenCalledWith( { type: 'executionWaiting', data: { executionId }, }, pushRef, ); }); describe('saving static data', () => { it('should skip saving static data for manual executions', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]); expect(workflowStaticDataService.saveStaticDataById).not.toHaveBeenCalled(); }); it('should save static data for prod executions', async () => { lifecycleHooks = createHooks('trigger'); await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]); expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith( workflowId, staticData, ); }); it('should handle static data saving errors', async () => { lifecycleHooks = createHooks('trigger'); const error = new Error('Static data save failed'); workflowStaticDataService.saveStaticDataById.mockRejectedValueOnce(error); await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]); expect(errorReporter.error).toHaveBeenCalledWith(error); }); }); describe('saving execution data', () => { it('should update execution with proper data', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); expect(executionRepository.updateExistingExecution).toHaveBeenCalledWith( executionId, expect.objectContaining({ finished: true, status: 'success', }), ); }); it('should not delete unfinished executions', async () => { const unfinishedRun = mock({ finished: false, status: 'running' }); await lifecycleHooks.runHook('workflowExecuteAfter', [unfinishedRun, {}]); expect(executionRepository.hardDelete).not.toHaveBeenCalled(); }); it('should not delete waiting executions', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [waitingRun, {}]); expect(executionRepository.hardDelete).not.toHaveBeenCalled(); }); it('should soft delete manual executions when manual saving is disabled', async () => { lifecycleHooks.workflowData.settings = { saveManualExecutions: false }; lifecycleHooks = createHooks(); await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); expect(executionRepository.softDelete).toHaveBeenCalledWith(executionId); }); it('should not soft delete manual executions with waitTill', async () => { lifecycleHooks.workflowData.settings = { saveManualExecutions: false }; lifecycleHooks = createHooks(); await lifecycleHooks.runHook('workflowExecuteAfter', [waitingRun, {}]); expect(executionRepository.softDelete).not.toHaveBeenCalled(); }); }); describe('error workflow', () => { it('should not execute error workflow for manual executions', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]); expect(workflowExecutionService.executeErrorWorkflow).not.toHaveBeenCalled(); }); it('should execute error workflow for failed non-manual executions', async () => { lifecycleHooks = createHooks('trigger'); const errorWorkflow = 'error-workflow-id'; workflowData.settings = { errorWorkflow }; const project = mock(); ownershipService.getWorkflowProjectCached .calledWith(workflowId) .mockResolvedValue(project); await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]); expect(workflowExecutionService.executeErrorWorkflow).toHaveBeenCalledWith( errorWorkflow, { workflow: { id: workflowId, name: workflowData.name, }, execution: { id: executionId, error: expressionError, mode: 'trigger', retryOf, lastNodeExecuted: undefined, url: `http://localhost:5678/workflow/${workflowId}/executions/${executionId}`, }, }, project, ); }); }); it('should restore binary data IDs after workflow execution for webhooks', async () => { mockInstance(BinaryDataConfig, { mode: 'filesystem' }); lifecycleHooks = createHooks('webhook'); (successfulRun.data.resultData.runData = { [nodeName]: [ { startTime: 1, executionIndex: 0, executionTime: 1, source: [], data: { main: [ [ { json: {}, binary: { data: { id: `filesystem-v2:workflows/${workflowId}/executions/temp/binary_data/123`, data: '', mimeType: 'text/plain', }, }, }, ], ], }, }, ], }), await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); expect(binaryDataService.rename).toHaveBeenCalledWith( 'workflows/test-workflow-id/executions/temp/binary_data/123', 'workflows/test-workflow-id/executions/test-execution-id/binary_data/123', ); }); }); describe("when pushRef isn't set", () => { beforeEach(() => { lifecycleHooks = getLifecycleHooksForRegularMain( { executionMode: 'manual', workflowData, retryOf }, executionId, ); }); it('should not setup any push hooks', async () => { const { handlers } = lifecycleHooks; expect(handlers.nodeExecuteBefore).toHaveLength(1); expect(handlers.nodeExecuteAfter).toHaveLength(1); expect(handlers.workflowExecuteBefore).toHaveLength(2); expect(handlers.workflowExecuteAfter).toHaveLength(4); await lifecycleHooks.runHook('nodeExecuteBefore', [nodeName, taskStartedData]); await lifecycleHooks.runHook('nodeExecuteAfter', [nodeName, taskData, runExecutionData]); await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); expect(push.send).not.toHaveBeenCalled(); }); }); }); describe('getLifecycleHooksForScalingMain', () => { beforeEach(() => { lifecycleHooks = getLifecycleHooksForScalingMain( { executionMode: 'manual', workflowData, pushRef, retryOf, userId, }, executionId, ); }); workflowEventTests(userId); externalHooksTests(); it('should setup the correct set of hooks', () => { expect(lifecycleHooks).toBeInstanceOf(ExecutionLifecycleHooks); expect(lifecycleHooks.mode).toBe('manual'); expect(lifecycleHooks.executionId).toBe(executionId); expect(lifecycleHooks.workflowData).toEqual(workflowData); const { handlers } = lifecycleHooks; expect(handlers.nodeExecuteBefore).toHaveLength(0); expect(handlers.nodeExecuteAfter).toHaveLength(0); expect(handlers.workflowExecuteBefore).toHaveLength(2); expect(handlers.workflowExecuteAfter).toHaveLength(4); expect(handlers.nodeFetchedData).toHaveLength(0); expect(handlers.sendResponse).toHaveLength(0); }); describe('workflowExecuteBefore', () => { it('should run the workflow.preExecute external hook', async () => { await lifecycleHooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); expect(externalHooks.run).toHaveBeenCalledWith('workflow.preExecute', [workflow, 'manual']); }); }); describe('workflowExecuteAfter', () => { it('should delete successful executions when success saving is disabled', async () => { workflowData.settings = { saveDataSuccessExecution: 'none', saveDataErrorExecution: 'all', }; const lifecycleHooks = getLifecycleHooksForScalingMain( { executionMode: 'webhook', workflowData, pushRef, retryOf, }, executionId, ); await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); expect(executionRepository.hardDelete).toHaveBeenCalledWith({ workflowId, executionId, }); }); it('should delete failed executions when error saving is disabled', async () => { workflowData.settings = { saveDataSuccessExecution: 'all', saveDataErrorExecution: 'none', }; const lifecycleHooks = getLifecycleHooksForScalingMain( { executionMode: 'webhook', workflowData, pushRef, retryOf, }, executionId, ); await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]); expect(executionRepository.hardDelete).toHaveBeenCalledWith({ workflowId, executionId, }); }); }); }); describe('getLifecycleHooksForScalingWorker', () => { const createHooks = (executionMode: WorkflowExecuteMode = 'manual') => getLifecycleHooksForScalingWorker( { executionMode, workflowData, pushRef, retryOf }, executionId, ); beforeEach(() => { lifecycleHooks = createHooks(); }); nodeEventsTests(); externalHooksTests(); statisticsTests(); it('should setup the correct set of hooks', () => { expect(lifecycleHooks).toBeInstanceOf(ExecutionLifecycleHooks); expect(lifecycleHooks.mode).toBe('manual'); expect(lifecycleHooks.executionId).toBe(executionId); expect(lifecycleHooks.workflowData).toEqual(workflowData); const { handlers } = lifecycleHooks; expect(handlers.nodeExecuteBefore).toHaveLength(2); expect(handlers.nodeExecuteAfter).toHaveLength(2); expect(handlers.workflowExecuteBefore).toHaveLength(2); expect(handlers.workflowExecuteAfter).toHaveLength(4); expect(handlers.nodeFetchedData).toHaveLength(1); expect(handlers.sendResponse).toHaveLength(0); }); describe('saving static data', () => { it('should skip saving static data for manual executions', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]); expect(workflowStaticDataService.saveStaticDataById).not.toHaveBeenCalled(); }); it('should save static data for prod executions', async () => { lifecycleHooks = createHooks('trigger'); await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]); expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith( workflowId, staticData, ); }); it('should handle static data saving errors', async () => { lifecycleHooks = createHooks('trigger'); const error = new Error('Static data save failed'); workflowStaticDataService.saveStaticDataById.mockRejectedValueOnce(error); await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, staticData]); expect(errorReporter.error).toHaveBeenCalledWith(error); }); }); describe('error workflow', () => { it('should not execute error workflow for manual executions', async () => { await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]); expect(workflowExecutionService.executeErrorWorkflow).not.toHaveBeenCalled(); }); it('should execute error workflow for failed non-manual executions', async () => { lifecycleHooks = createHooks('trigger'); const errorWorkflow = 'error-workflow-id'; workflowData.settings = { errorWorkflow }; const project = mock(); ownershipService.getWorkflowProjectCached.calledWith(workflowId).mockResolvedValue(project); await lifecycleHooks.runHook('workflowExecuteAfter', [failedRun, {}]); expect(workflowExecutionService.executeErrorWorkflow).toHaveBeenCalledWith( errorWorkflow, { workflow: { id: workflowId, name: workflowData.name, }, execution: { id: executionId, error: expressionError, mode: 'trigger', retryOf, lastNodeExecuted: undefined, url: `http://localhost:5678/workflow/${workflowId}/executions/${executionId}`, }, }, project, ); }); }); }); describe('getLifecycleHooksForSubExecutions', () => { beforeEach(() => { lifecycleHooks = getLifecycleHooksForSubExecutions( 'manual', executionId, workflowData, undefined, ); }); workflowEventTests(); nodeEventsTests(); externalHooksTests(); statisticsTests(); it('should setup the correct set of hooks', () => { expect(lifecycleHooks).toBeInstanceOf(ExecutionLifecycleHooks); expect(lifecycleHooks.mode).toBe('manual'); expect(lifecycleHooks.executionId).toBe(executionId); expect(lifecycleHooks.workflowData).toEqual(workflowData); const { handlers } = lifecycleHooks; expect(handlers.nodeExecuteBefore).toHaveLength(1); expect(handlers.nodeExecuteAfter).toHaveLength(1); expect(handlers.workflowExecuteBefore).toHaveLength(2); expect(handlers.workflowExecuteAfter).toHaveLength(4); expect(handlers.nodeFetchedData).toHaveLength(1); expect(handlers.sendResponse).toHaveLength(0); }); }); });