diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index 3ad823d4d5..c70e0b20e8 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -4,6 +4,10 @@ import { getVisiblePopper, getVisibleSelect } from '../utils/popper'; +export function getNdvContainer() { + return cy.getByTestId('ndv'); +} + export function getCredentialSelect(eq = 0) { return cy.getByTestId('node-credentials-select').eq(eq); } diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index d50c1e1255..7d783c1d3c 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -101,8 +101,8 @@ export function getNodeCreatorItems() { return cy.getByTestId('item-iterator-item'); } -export function getExecuteWorkflowButton() { - return cy.getByTestId('execute-workflow-button'); +export function getExecuteWorkflowButton(triggerNodeName?: string) { + return cy.getByTestId(`execute-workflow-button${triggerNodeName ? `-${triggerNodeName}` : ''}`); } export function getManualChatButton() { @@ -294,8 +294,8 @@ export function addRetrieverNodeToParent(nodeName: string, parentNodeName: strin addSupplementalNodeToParent(nodeName, 'ai_retriever', parentNodeName); } -export function clickExecuteWorkflowButton() { - getExecuteWorkflowButton().click(); +export function clickExecuteWorkflowButton(triggerNodeName?: string) { + getExecuteWorkflowButton(triggerNodeName).click(); } export function clickManualChatButton() { diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 4a39af1d99..7563cc4827 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -1,3 +1,11 @@ +import { clickGetBackToCanvas, getNdvContainer, getOutputTableRow } from '../composables/ndv'; +import { + clickExecuteWorkflowButton, + getExecuteWorkflowButton, + getNodeByName, + getZoomToFitButton, + openNode, +} from '../composables/workflow'; import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants'; import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages'; import { clearNotifications, errorToast, successToast } from '../pages/notifications'; @@ -214,6 +222,39 @@ describe('Execution', () => { workflowPage.getters.clearExecutionDataButton().should('not.exist'); }); + it('should test workflow with specific trigger node', () => { + cy.createFixtureWorkflow('Two_schedule_triggers.json'); + + getZoomToFitButton().click(); + getExecuteWorkflowButton('Trigger A').should('not.be.visible'); + getExecuteWorkflowButton('Trigger B').should('not.be.visible'); + + // Execute the workflow from trigger A + getNodeByName('Trigger A').realHover(); + getExecuteWorkflowButton('Trigger A').should('be.visible'); + getExecuteWorkflowButton('Trigger B').should('not.be.visible'); + clickExecuteWorkflowButton('Trigger A'); + + // Check the output + successToast().contains('Workflow executed successfully'); + openNode('Edit Fields'); + getOutputTableRow(1).should('include.text', 'Trigger A'); + + clickGetBackToCanvas(); + getNdvContainer().should('not.be.visible'); + + // Execute the workflow from trigger B + getNodeByName('Trigger B').realHover(); + getExecuteWorkflowButton('Trigger A').should('not.be.visible'); + getExecuteWorkflowButton('Trigger B').should('be.visible'); + clickExecuteWorkflowButton('Trigger B'); + + // Check the output + successToast().contains('Workflow executed successfully'); + openNode('Edit Fields'); + getOutputTableRow(1).should('include.text', 'Trigger B'); + }); + describe('execution preview', () => { it('when deleting the last execution, it should show empty state', () => { workflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); diff --git a/cypress/fixtures/Two_schedule_triggers.json b/cypress/fixtures/Two_schedule_triggers.json new file mode 100644 index 0000000000..a990b4a448 --- /dev/null +++ b/cypress/fixtures/Two_schedule_triggers.json @@ -0,0 +1,76 @@ +{ + "nodes": [ + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "6a8c3d85-26f8-4f28-ace9-55a196a23d37", + "name": "prevNode", + "value": "={{ $prevNode.name }}", + "type": "string" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [200, -100], + "id": "351ce967-0399-4a78-848a-9cc69b831796", + "name": "Edit Fields" + }, + { + "parameters": { + "rule": { + "interval": [{}] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [0, -100], + "id": "cf2f58a8-1fbb-4c70-b2b1-9e06bee7ec47", + "name": "Trigger A" + }, + { + "parameters": { + "rule": { + "interval": [{}] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [0, 100], + "id": "4fade34e-2bfc-4a2e-a8ed-03ab2ed9c690", + "name": "Trigger B" + } + ], + "connections": { + "Trigger A": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + }, + "Trigger B": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "meta": { + "instanceId": "0dd4627b77a5a795ab9bf073e5812be94dd8d1a5f012248ef2a4acac09be12cb" + } +} diff --git a/packages/cli/src/__tests__/manual-execution.service.test.ts b/packages/cli/src/__tests__/manual-execution.service.test.ts index 383a8dc87c..23f69ab821 100644 --- a/packages/cli/src/__tests__/manual-execution.service.test.ts +++ b/packages/cli/src/__tests__/manual-execution.service.test.ts @@ -46,5 +46,26 @@ describe('ManualExecutionService', () => { name: 'node2', }); }); + + it('Should return triggerToStartFrom trigger node', () => { + const data = { + pinData: { + node1: {}, + node2: {}, + }, + triggerToStartFrom: { name: 'node3' }, + } as unknown as IWorkflowExecutionDataProcess; + const workflow = { + getNode(nodeName: string) { + return { + name: nodeName, + }; + }, + } as unknown as Workflow; + const executionStartNode = manualExecutionService.getExecutionStartNode(data, workflow); + expect(executionStartNode).toEqual({ + name: 'node3', + }); + }); }); }); diff --git a/packages/cli/src/manual-execution.service.ts b/packages/cli/src/manual-execution.service.ts index 6f55130b25..4d6754ff4e 100644 --- a/packages/cli/src/manual-execution.service.ts +++ b/packages/cli/src/manual-execution.service.ts @@ -23,6 +23,13 @@ export class ManualExecutionService { getExecutionStartNode(data: IWorkflowExecutionDataProcess, workflow: Workflow) { let startNode; + + // If the user chose a trigger to start from we honor this. + if (data.triggerToStartFrom?.name) { + startNode = workflow.getNode(data.triggerToStartFrom.name) ?? undefined; + } + + // Old logic for partial executions v1 if ( data.startNodes?.length === 1 && Object.keys(data.pinData ?? {}).includes(data.startNodes[0].name) diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index 3d0bec39de..00a81700e0 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -1,11 +1,15 @@ import { mock } from 'jest-mock-extended'; -import type { INode } from 'n8n-workflow'; +import type { INode, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; +import type { User } from '@/databases/entities/user'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { IWorkflowDb } from '@/interfaces'; +import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import type { WorkflowRunner } from '@/workflow-runner'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; +import type { WorkflowRequest } from '../workflow.request'; + const webhookNode: INode = { name: 'Webhook', type: 'n8n-nodes-base.webhook', @@ -63,6 +67,9 @@ describe('WorkflowExecutionService', () => { mock(), ); + const additionalData = mock({}); + jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData); + describe('runWorkflow()', () => { test('should call `WorkflowRunner.run()`', async () => { const node = mock(); @@ -76,6 +83,222 @@ describe('WorkflowExecutionService', () => { }); }); + describe('executeManually()', () => { + test('should call `WorkflowRunner.run()` with correct parameters with default partial execution logic', async () => { + const executionId = 'fake-execution-id'; + const userId = 'user-id'; + const user = mock({ id: userId }); + const runPayload = mock({ startNodes: [] }); + + workflowRunner.run.mockResolvedValue(executionId); + + const result = await workflowExecutionService.executeManually(runPayload, user); + + expect(workflowRunner.run).toHaveBeenCalledWith({ + destinationNode: runPayload.destinationNode, + executionMode: 'manual', + runData: runPayload.runData, + pinData: undefined, + pushRef: undefined, + workflowData: runPayload.workflowData, + userId, + partialExecutionVersion: 1, + startNodes: runPayload.startNodes, + dirtyNodeNames: runPayload.dirtyNodeNames, + triggerToStartFrom: runPayload.triggerToStartFrom, + }); + expect(result).toEqual({ executionId }); + }); + + [ + { + name: 'trigger', + type: 'n8n-nodes-base.airtableTrigger', + // Avoid mock constructor evaluated as true + disabled: undefined, + }, + { + name: 'webhook', + type: 'n8n-nodes-base.webhook', + disabled: undefined, + }, + ].forEach((triggerNode: Partial) => { + test(`should call WorkflowRunner.run() with pinned trigger with type ${triggerNode.name}`, async () => { + const additionalData = mock({}); + jest.spyOn(WorkflowExecuteAdditionalData, 'getBase').mockResolvedValue(additionalData); + const executionId = 'fake-execution-id'; + const userId = 'user-id'; + const user = mock({ id: userId }); + const runPayload = mock({ + startNodes: [], + workflowData: { + pinData: { + trigger: [{}], + }, + nodes: [triggerNode], + }, + triggerToStartFrom: undefined, + }); + + workflowRunner.run.mockResolvedValue(executionId); + + const result = await workflowExecutionService.executeManually(runPayload, user); + + expect(workflowRunner.run).toHaveBeenCalledWith({ + destinationNode: runPayload.destinationNode, + executionMode: 'manual', + runData: runPayload.runData, + pinData: runPayload.workflowData.pinData, + pushRef: undefined, + workflowData: runPayload.workflowData, + userId, + partialExecutionVersion: 1, + startNodes: [ + { + name: triggerNode.name, + sourceData: null, + }, + ], + dirtyNodeNames: runPayload.dirtyNodeNames, + triggerToStartFrom: runPayload.triggerToStartFrom, + }); + expect(result).toEqual({ executionId }); + }); + }); + + test('should start from pinned trigger', async () => { + const executionId = 'fake-execution-id'; + const userId = 'user-id'; + const user = mock({ id: userId }); + + const pinnedTrigger: INode = { + id: '1', + typeVersion: 1, + position: [1, 2], + parameters: {}, + name: 'pinned', + type: 'n8n-nodes-base.airtableTrigger', + }; + + const unexecutedTrigger: INode = { + id: '1', + typeVersion: 1, + position: [1, 2], + parameters: {}, + name: 'to-start-from', + type: 'n8n-nodes-base.airtableTrigger', + }; + + const runPayload: WorkflowRequest.ManualRunPayload = { + startNodes: [], + workflowData: { + id: 'abc', + name: 'test', + active: false, + pinData: { + [pinnedTrigger.name]: [{ json: {} }], + }, + nodes: [unexecutedTrigger, pinnedTrigger], + connections: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + runData: {}, + }; + + workflowRunner.run.mockResolvedValue(executionId); + + const result = await workflowExecutionService.executeManually(runPayload, user); + + expect(workflowRunner.run).toHaveBeenCalledWith({ + destinationNode: runPayload.destinationNode, + executionMode: 'manual', + runData: runPayload.runData, + pinData: runPayload.workflowData.pinData, + pushRef: undefined, + workflowData: runPayload.workflowData, + userId, + partialExecutionVersion: 1, + startNodes: [ + { + // Start from pinned trigger + name: pinnedTrigger.name, + sourceData: null, + }, + ], + dirtyNodeNames: runPayload.dirtyNodeNames, + // no trigger to start from + triggerToStartFrom: undefined, + }); + expect(result).toEqual({ executionId }); + }); + + test('should ignore pinned trigger and start from unexecuted trigger', async () => { + const executionId = 'fake-execution-id'; + const userId = 'user-id'; + const user = mock({ id: userId }); + + const pinnedTrigger: INode = { + id: '1', + typeVersion: 1, + position: [1, 2], + parameters: {}, + name: 'pinned', + type: 'n8n-nodes-base.airtableTrigger', + }; + + const unexecutedTrigger: INode = { + id: '1', + typeVersion: 1, + position: [1, 2], + parameters: {}, + name: 'to-start-from', + type: 'n8n-nodes-base.airtableTrigger', + }; + + const runPayload: WorkflowRequest.ManualRunPayload = { + startNodes: [], + workflowData: { + id: 'abc', + name: 'test', + active: false, + pinData: { + [pinnedTrigger.name]: [{ json: {} }], + }, + nodes: [unexecutedTrigger, pinnedTrigger], + connections: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + runData: {}, + triggerToStartFrom: { + name: unexecutedTrigger.name, + }, + }; + + workflowRunner.run.mockResolvedValue(executionId); + + const result = await workflowExecutionService.executeManually(runPayload, user); + + expect(workflowRunner.run).toHaveBeenCalledWith({ + destinationNode: runPayload.destinationNode, + executionMode: 'manual', + runData: runPayload.runData, + pinData: runPayload.workflowData.pinData, + pushRef: undefined, + workflowData: runPayload.workflowData, + userId, + partialExecutionVersion: 1, + // ignore pinned trigger + startNodes: [], + dirtyNodeNames: runPayload.dirtyNodeNames, + // pass unexecuted trigger to start from + triggerToStartFrom: runPayload.triggerToStartFrom, + }); + expect(result).toEqual({ executionId }); + }); + }); + describe('selectPinnedActivatorStarter()', () => { const workflow = mock({ nodes: [], diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index cd6f936717..23394493df 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -100,12 +100,18 @@ export class WorkflowExecutionService { partialExecutionVersion: 1 | 2 = 1, ) { const pinData = workflowData.pinData; - const pinnedTrigger = this.selectPinnedActivatorStarter( + let pinnedTrigger = this.selectPinnedActivatorStarter( workflowData, startNodes?.map((nodeData) => nodeData.name), pinData, ); + // if we have a trigger to start from and it's not the pinned trigger + // ignore the pinned trigger + if (pinnedTrigger && triggerToStartFrom && pinnedTrigger.name !== triggerToStartFrom.name) { + pinnedTrigger = null; + } + // If webhooks nodes exist and are active we have to wait for till we receive a call if ( pinnedTrigger === null && diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index fc4ebbce15..7f7e445fde 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -405,6 +405,7 @@ export interface IExecutionResponse extends IExecutionBase { data?: IRunExecutionData; workflowData: IWorkflowDb; executedNode?: string; + triggerNode?: string; } export type ExecutionSummaryWithScopes = ExecutionSummary & { scopes: Scope[] }; diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue index 34a3756f9f..c414a0641f 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -1,10 +1,11 @@