diff --git a/packages/cli/src/__tests__/manual-execution.service.test.ts b/packages/cli/src/__tests__/manual-execution.service.test.ts index 23f69ab821..e9b74172e3 100644 --- a/packages/cli/src/__tests__/manual-execution.service.test.ts +++ b/packages/cli/src/__tests__/manual-execution.service.test.ts @@ -1,8 +1,24 @@ import { mock } from 'jest-mock-extended'; -import type { Workflow, IWorkflowExecutionDataProcess } from 'n8n-workflow'; +import * as core from 'n8n-core'; +import { DirectedGraph, recreateNodeExecutionStack, WorkflowExecute } from 'n8n-core'; +import type { + Workflow, + IWorkflowExecutionDataProcess, + IWorkflowExecuteAdditionalData, + IPinData, + ITaskData, + INode, + IRun, + IExecuteData, + IWaitingForExecution, + IWaitingForExecutionSource, +} from 'n8n-workflow'; +import type PCancelable from 'p-cancelable'; import { ManualExecutionService } from '@/manual-execution.service'; +jest.mock('n8n-core'); + describe('ManualExecutionService', () => { const manualExecutionService = new ManualExecutionService(mock()); @@ -68,4 +84,365 @@ describe('ManualExecutionService', () => { }); }); }); + + describe('runManually', () => { + const nodeExecutionStack = mock(); + const waitingExecution = mock(); + const waitingExecutionSource = mock(); + const mockFilteredGraph = mock(); + + beforeEach(() => { + jest.spyOn(DirectedGraph, 'fromWorkflow').mockReturnValue(mock()); + jest.spyOn(core, 'WorkflowExecute').mockReturnValue( + mock({ + processRunExecutionData: jest.fn().mockReturnValue(mock>()), + }), + ); + jest.spyOn(core, 'recreateNodeExecutionStack').mockReturnValue({ + nodeExecutionStack, + waitingExecution, + waitingExecutionSource, + }); + jest.spyOn(core, 'filterDisabledNodes').mockReturnValue(mockFilteredGraph); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should correctly process triggerToStartFrom data when data.triggerToStartFrom.data is present', async () => { + const mockTriggerData = mock(); + const startNodeName = 'startNode'; + const triggerNodeName = 'triggerNode'; + const data = mock({ + triggerToStartFrom: { + name: triggerNodeName, + data: mockTriggerData, + }, + startNodes: [{ name: startNodeName }], + executionMode: 'manual', + pinData: undefined, + }); + + const startNode = mock({ name: startNodeName }); + const workflow = mock({ + getNode: jest.fn((name) => { + if (name === startNodeName) return startNode; + return null; + }), + }); + + const additionalData = mock(); + const executionId = 'test-execution-id'; + const pinData: IPinData = {}; + + await manualExecutionService.runManually( + data, + workflow, + additionalData, + executionId, + pinData, + ); + + expect(DirectedGraph.fromWorkflow).toHaveBeenCalledWith(workflow); + + expect(recreateNodeExecutionStack).toHaveBeenCalledWith( + mockFilteredGraph, + new Set([startNode]), + { [triggerNodeName]: [mockTriggerData] }, + {}, + ); + + expect(WorkflowExecute).toHaveBeenCalledWith( + additionalData, + data.executionMode, + expect.objectContaining({ + resultData: { + runData: { [triggerNodeName]: [mockTriggerData] }, + pinData, + }, + executionData: { + contextData: {}, + metadata: {}, + nodeExecutionStack, + waitingExecution, + waitingExecutionSource, + }, + }), + ); + }); + + it('should correctly include destinationNode in executionData when provided', async () => { + const mockTriggerData = mock(); + const startNodeName = 'startNode'; + const triggerNodeName = 'triggerNode'; + const destinationNodeName = 'destinationNode'; + + const data = mock({ + triggerToStartFrom: { + name: triggerNodeName, + data: mockTriggerData, + }, + startNodes: [{ name: startNodeName }], + executionMode: 'manual', + destinationNode: destinationNodeName, + }); + + const startNode = mock({ name: startNodeName }); + const workflow = mock({ + getNode: jest.fn((name) => { + if (name === startNodeName) return startNode; + return null; + }), + }); + + const additionalData = mock(); + const executionId = 'test-execution-id'; + const pinData: IPinData = {}; + + await manualExecutionService.runManually( + data, + workflow, + additionalData, + executionId, + pinData, + ); + + expect(WorkflowExecute).toHaveBeenCalledWith( + additionalData, + data.executionMode, + expect.objectContaining({ + startData: { + destinationNode: destinationNodeName, + }, + resultData: expect.any(Object), + executionData: expect.any(Object), + }), + ); + }); + + it('should call workflowExecute.run for full execution when no runData or startNodes', async () => { + const data = mock({ + executionMode: 'manual', + destinationNode: undefined, + pinData: undefined, + }); + + const workflow = mock({ + getNode: jest.fn().mockReturnValue(null), + }); + + const additionalData = mock(); + const executionId = 'test-execution-id'; + + const mockRun = jest.fn().mockReturnValue('mockRunReturn'); + require('n8n-core').WorkflowExecute.mockImplementationOnce(() => ({ + run: mockRun, + processRunExecutionData: jest.fn(), + })); + + await manualExecutionService.runManually(data, workflow, additionalData, executionId); + + expect(mockRun).toHaveBeenCalledWith( + workflow, + undefined, // startNode + undefined, // destinationNode + undefined, // pinData + ); + }); + + it('should use execution start node when available for full execution', async () => { + const data = mock({ + executionMode: 'manual', + pinData: {}, + startNodes: [], + destinationNode: undefined, + }); + + const startNode = mock({ name: 'startNode' }); + const workflow = mock({ + getNode: jest.fn().mockReturnValue(startNode), + }); + + const additionalData = mock(); + const executionId = 'test-execution-id'; + const emptyPinData = {}; + + jest.spyOn(manualExecutionService, 'getExecutionStartNode').mockReturnValue(startNode); + + const mockRun = jest.fn().mockReturnValue('mockRunReturn'); + require('n8n-core').WorkflowExecute.mockImplementationOnce(() => ({ + run: mockRun, + processRunExecutionData: jest.fn(), + })); + + await manualExecutionService.runManually( + data, + workflow, + additionalData, + executionId, + emptyPinData, + ); + + expect(manualExecutionService.getExecutionStartNode).toHaveBeenCalledWith(data, workflow); + + expect(mockRun).toHaveBeenCalledWith( + workflow, + startNode, // startNode + undefined, // destinationNode + data.pinData, // pinData + ); + }); + + it('should handle partial execution with provided runData, startNodes and no destinationNode', async () => { + const mockRunData = { node1: [{ data: { main: [[{ json: {} }]] } }] }; + const startNodeName = 'node1'; + const data = mock({ + executionMode: 'manual', + runData: mockRunData, + startNodes: [{ name: startNodeName }], + destinationNode: undefined, + pinData: undefined, + }); + + const workflow = mock({ + getNode: jest.fn((name) => { + if (name === startNodeName) return mock({ name: startNodeName }); + return null; + }), + }); + + const additionalData = mock(); + const executionId = 'test-execution-id'; + + const mockRunPartialWorkflow = jest.fn().mockReturnValue('mockPartialReturn'); + require('n8n-core').WorkflowExecute.mockImplementationOnce(() => ({ + runPartialWorkflow: mockRunPartialWorkflow, + processRunExecutionData: jest.fn(), + })); + + await manualExecutionService.runManually(data, workflow, additionalData, executionId); + + expect(mockRunPartialWorkflow).toHaveBeenCalledWith( + workflow, + mockRunData, + data.startNodes, + undefined, // destinationNode + undefined, // pinData + ); + }); + + it('should handle partial execution with partialExecutionVersion=2', async () => { + const mockRunData = { node1: [{ data: { main: [[{ json: {} }]] } }] }; + const dirtyNodeNames = ['node2', 'node3']; + const destinationNodeName = 'destinationNode'; + const data = mock({ + executionMode: 'manual', + runData: mockRunData, + startNodes: [{ name: 'node1' }], + partialExecutionVersion: 2, + dirtyNodeNames, + destinationNode: destinationNodeName, + }); + + const workflow = mock({ + getNode: jest.fn((name) => mock({ name })), + }); + + const additionalData = mock(); + const executionId = 'test-execution-id'; + const pinData: IPinData = { node1: [{ json: { pinned: true } }] }; + + const mockRunPartialWorkflow2 = jest.fn().mockReturnValue('mockPartial2Return'); + require('n8n-core').WorkflowExecute.mockImplementationOnce(() => ({ + runPartialWorkflow2: mockRunPartialWorkflow2, + processRunExecutionData: jest.fn(), + })); + + await manualExecutionService.runManually( + data, + workflow, + additionalData, + executionId, + pinData, + ); + + expect(mockRunPartialWorkflow2).toHaveBeenCalled(); + expect(mockRunPartialWorkflow2.mock.calls[0][0]).toBe(workflow); + expect(mockRunPartialWorkflow2.mock.calls[0][4]).toBe(destinationNodeName); + }); + + it('should validate nodes exist before execution', async () => { + const startNodeName = 'existingNode'; + const data = mock({ + triggerToStartFrom: { + name: 'triggerNode', + data: mock(), + }, + startNodes: [{ name: startNodeName }], + executionMode: 'manual', + }); + + const workflow = mock({ + getNode: jest.fn((name) => { + if (name === startNodeName) return mock({ name: startNodeName }); + return null; + }), + }); + + const additionalData = mock(); + const executionId = 'test-execution-id'; + + await manualExecutionService.runManually(data, workflow, additionalData, executionId); + + expect(workflow.getNode).toHaveBeenCalledWith(startNodeName); + }); + + it('should handle pinData correctly when provided', async () => { + const startNodeName = 'startNode'; + const triggerNodeName = 'triggerNode'; + const mockTriggerData = mock(); + const mockPinData: IPinData = { + [startNodeName]: [{ json: { pinned: true } }], + }; + + const data = mock({ + triggerToStartFrom: { + name: triggerNodeName, + data: mockTriggerData, + }, + startNodes: [{ name: startNodeName }], + executionMode: 'manual', + }); + + const startNode = mock({ name: startNodeName }); + const workflow = mock({ + getNode: jest.fn((name) => { + if (name === startNodeName) return startNode; + return null; + }), + }); + + const additionalData = mock(); + const executionId = 'test-execution-id'; + + await manualExecutionService.runManually( + data, + workflow, + additionalData, + executionId, + mockPinData, + ); + + expect(WorkflowExecute).toHaveBeenCalledWith( + additionalData, + data.executionMode, + expect.objectContaining({ + resultData: expect.objectContaining({ + pinData: mockPinData, + }), + }), + ); + }); + }); }); diff --git a/packages/cli/src/manual-execution.service.ts b/packages/cli/src/manual-execution.service.ts index 4d6754ff4e..78b7e1723f 100644 --- a/packages/cli/src/manual-execution.service.ts +++ b/packages/cli/src/manual-execution.service.ts @@ -48,7 +48,7 @@ export class ManualExecutionService { executionId: string, pinData?: IPinData, ): PCancelable { - if (data.triggerToStartFrom?.data && data.startNodes && !data.destinationNode) { + if (data.triggerToStartFrom?.data && data.startNodes) { this.logger.debug( `Execution ID ${executionId} had triggerToStartFrom. Starting from that trigger.`, { executionId }, @@ -78,6 +78,10 @@ export class ManualExecutionService { }, }; + if (data.destinationNode) { + executionData.startData = { destinationNode: data.destinationNode }; + } + const workflowExecute = new WorkflowExecute( additionalData, data.executionMode, @@ -105,6 +109,7 @@ export class ManualExecutionService { // Can execute without webhook so go on const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); + return workflowExecute.run(workflow, startNode, data.destinationNode, data.pinData); } else { // Partial Execution