| 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 nodeType = 'n8n-nodes-base.testNode'; | |
| const nodeId = 'test-node-id'; | |
| const node = mock<INode>(); | |
| const workflowId = 'test-workflow-id'; | |
| const executionId = 'test-execution-id'; | |
| const workflowData: IWorkflowBase = { | |
| id: workflowId, | |
| name: 'Test Workflow', | |
| active: true, | |
| isArchived: false, | |
| connections: {}, | |
| nodes: [ | |
| { | |
| id: nodeId, | |
| name: nodeName, | |
| type: nodeType, | |
| typeVersion: 1, | |
| position: [100, 200], | |
| parameters: {}, | |
| }, | |
| ], | |
| settings: {}, | |
| createdAt: new Date(), | |
| updatedAt: new Date(), | |
| }; | |
| const workflow = mock<Workflow>(); | |
| const staticData = mock<IDataObject>(); | |
| const taskStartedData = mock<ITaskStartedData>(); | |
| const taskData = mock<ITaskData>(); | |
| const runExecutionData = mock<IRunExecutionData>(); | |
| const successfulRunWithRewiredDestination = mock<IRun>({ | |
| status: 'success', | |
| finished: true, | |
| waitTill: undefined, | |
| }); | |
| const successfulRun = mock<IRun>({ | |
| status: 'success', | |
| finished: true, | |
| waitTill: undefined, | |
| }); | |
| const failedRun = mock<IRun>({ | |
| status: 'error', | |
| finished: true, | |
| waitTill: undefined, | |
| }); | |
| const waitingRun = mock<IRun>({ | |
| 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, | |
| }, | |
| }; | |
| successfulRunWithRewiredDestination.data = { | |
| startData: { | |
| destinationNode: 'PartialExecutionToolExecutor', | |
| originalDestinationNode: nodeName, | |
| }, | |
| resultData: { | |
| runData: {}, | |
| }, | |
| }; | |
| }); | |
| 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'); | |
| }); | |
| it('should reset destination node to original destination', async () => { | |
| await lifecycleHooks.runHook('workflowExecuteAfter', [ | |
| successfulRunWithRewiredDestination, | |
| {}, | |
| ]); | |
| expect(eventService.emit).toHaveBeenCalledWith('workflow-post-execute', { | |
| executionId, | |
| runData: successfulRunWithRewiredDestination, | |
| workflow: workflowData, | |
| userId: expectedUserId, | |
| }); | |
| expect(successfulRunWithRewiredDestination.data.startData?.destinationNode).toBe(nodeName); | |
| expect( | |
| successfulRunWithRewiredDestination.data.startData?.originalDestinationNode, | |
| ).toBeUndefined(); | |
| }); | |
| }); | |
| }; | |
| 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, | |
| nodeType, | |
| nodeId, | |
| }); | |
| }); | |
| }); | |
| 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, | |
| nodeType, | |
| nodeId, | |
| }); | |
| }); | |
| }); | |
| }; | |
| 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<IRun>({ 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<Project>(); | |
| 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<Project>(); | |
| 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); | |
| }); | |
| }); | |
| }); | |