diff --git a/packages/@n8n/api-types/src/push/execution.ts b/packages/@n8n/api-types/src/push/execution.ts index b2a9b5b66dd..9a839b69804 100644 --- a/packages/@n8n/api-types/src/push/execution.ts +++ b/packages/@n8n/api-types/src/push/execution.ts @@ -65,7 +65,7 @@ export type NodeExecuteAfter = { /** * The data field for task data in `NodeExecuteAfter` is always trimmed (undefined). */ - data: ITaskData; + data: Omit; /** * The number of items per output connection type. This is needed so that the frontend * can know how many items to expect when receiving the `NodeExecuteAfterData` message. diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.test.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.test.ts index 2df8bf3cc54..2a0bbd7cc54 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.test.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.test.ts @@ -37,14 +37,14 @@ describe('nodeExecuteAfter', () => { await nodeExecuteAfter(event); - expect(workflowsStore.updateNodeExecutionData).toHaveBeenCalledTimes(1); + expect(workflowsStore.updateNodeExecutionStatus).toHaveBeenCalledTimes(1); expect(workflowsStore.removeExecutingNode).toHaveBeenCalledTimes(1); expect(workflowsStore.removeExecutingNode).toHaveBeenCalledWith('Test Node'); expect(assistantStore.onNodeExecution).toHaveBeenCalledTimes(1); expect(assistantStore.onNodeExecution).toHaveBeenCalledWith(event.data); // Verify the placeholder data structure - const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0]; expect(updateCall.data.data).toEqual({ main: [ Array.from({ length: 2 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), @@ -77,7 +77,7 @@ describe('nodeExecuteAfter', () => { await nodeExecuteAfter(event); - const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0]; expect(updateCall.data.data).toEqual({ main: [ Array.from({ length: 3 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), @@ -112,7 +112,7 @@ describe('nodeExecuteAfter', () => { await nodeExecuteAfter(event); - const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0]; expect(updateCall.data.data).toEqual({ main: [], }); @@ -138,7 +138,7 @@ describe('nodeExecuteAfter', () => { await nodeExecuteAfter(event); - const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0]; expect(updateCall.executionId).toBe('exec-1'); expect(updateCall.nodeName).toBe('Test Node'); expect(updateCall.data.executionTime).toBe(100); @@ -178,7 +178,7 @@ describe('nodeExecuteAfter', () => { await nodeExecuteAfter(event); - const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + const updateCall = workflowsStore.updateNodeExecutionStatus.mock.calls[0][0]; // Should only contain main connection, invalid_connection should be filtered out expect(updateCall.data.data).toEqual({ main: [ diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.ts index d046eaa2307..45d5bb823c5 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.ts @@ -46,7 +46,7 @@ export async function nodeExecuteAfter({ data: pushData }: NodeExecuteAfter) { }, }; - workflowsStore.updateNodeExecutionData(pushDataWithPlaceholderOutputData); + workflowsStore.updateNodeExecutionStatus(pushDataWithPlaceholderOutputData); workflowsStore.removeExecutingNode(pushData.nodeName); void assistantStore.onNodeExecution(pushData); diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.test.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.test.ts index 5c1f657d1ca..93a0fe74371 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.test.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.test.ts @@ -36,7 +36,7 @@ describe('nodeExecuteAfterData', () => { await nodeExecuteAfterData(event); - expect(workflowsStore.updateNodeExecutionData).toHaveBeenCalledTimes(1); - expect(workflowsStore.updateNodeExecutionData).toHaveBeenCalledWith(event.data); + expect(workflowsStore.updateNodeExecutionRunData).toHaveBeenCalledTimes(1); + expect(workflowsStore.updateNodeExecutionRunData).toHaveBeenCalledWith(event.data); }); }); diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.ts index 831da59255c..166ba7a3d83 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.ts @@ -9,7 +9,7 @@ export async function nodeExecuteAfterData({ data: pushData }: NodeExecuteAfterD const workflowsStore = useWorkflowsStore(); const schemaPreviewStore = useSchemaPreviewStore(); - workflowsStore.updateNodeExecutionData(pushData); + workflowsStore.updateNodeExecutionRunData(pushData); void schemaPreviewStore.trackSchemaPreviewExecution(pushData); } diff --git a/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts b/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts index 8f73356a662..df311fdaffe 100644 --- a/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts @@ -1,6 +1,7 @@ import type { IExecutionPushResponse, IExecutionResponse, + IExecutionsStopData, IStartRunData, IWorkflowDb, } from '@/Interface'; @@ -476,12 +477,14 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { const execution = await workflowsStore.getExecution(executionId); if (!['running', 'waiting'].includes(execution?.status as string)) { - workflowsStore.markExecutionAsStopped(); + workflowsStore.markExecutionAsStopped(stopData); return true; } @@ -529,7 +532,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType { expect(lastTreeItem.getByText('AI Agent')).toBeInTheDocument(); expect(lastTreeItem.getByText(/Running/)).toBeInTheDocument(); - workflowsStore.updateNodeExecutionData({ + workflowsStore.updateNodeExecutionStatus({ nodeName: 'AI Agent', executionId: '567', itemCountByConnectionType: { ai_agent: [1] }, diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts index 4350f86aa33..e2fe9cfb9ab 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts @@ -31,7 +31,13 @@ import * as apiUtils from '@n8n/rest-api-client'; import { useSettingsStore } from '@/stores/settings.store'; import { useLocalStorage } from '@vueuse/core'; import { ref } from 'vue'; -import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks'; +import { + createTestNode, + createTestTaskData, + createTestWorkflow, + createTestWorkflowExecutionResponse, + mockNodeTypeDescription, +} from '@/__tests__/mocks'; import { waitFor } from '@testing-library/vue'; vi.mock('@/stores/ndv.store', () => ({ @@ -657,7 +663,90 @@ describe('useWorkflowsStore', () => { }); }); - describe('updateNodeExecutionData', () => { + describe('updateNodeExecutionRunData', () => { + beforeEach(() => { + workflowsStore.workflowExecutionData = createTestWorkflowExecutionResponse({ + id: 'test-execution', + data: { + resultData: { + runData: { + n0: [ + createTestTaskData({ + executionIndex: 0, + executionStatus: 'success', + executionTime: 33, + }), + createTestTaskData({ + executionIndex: 1, + executionStatus: 'success', + executionTime: 44, + }), + createTestTaskData({ + executionIndex: 2, + executionStatus: 'running', + executionTime: undefined, + }), + ], + }, + }, + }, + }); + }); + + it('should replace run data at the matched index in the execution data', () => { + workflowsStore.updateNodeExecutionRunData({ + executionId: 'test-execution', + nodeName: 'n0', + data: createTestTaskData({ + executionIndex: 2, + executionStatus: 'success', + executionTime: 100, + }), + itemCountByConnectionType: { main: [1] }, + }); + + const runData = workflowsStore.workflowExecutionData?.data?.resultData?.runData.n0; + + expect(runData).toHaveLength(3); + expect(runData?.[0].executionIndex).toBe(0); + expect(runData?.[0].executionStatus).toBe('success'); + expect(runData?.[0].executionTime).toBe(33); + expect(runData?.[1].executionIndex).toBe(1); + expect(runData?.[1].executionStatus).toBe('success'); + expect(runData?.[1].executionTime).toBe(44); + expect(runData?.[2].executionIndex).toBe(2); + expect(runData?.[2].executionStatus).toBe('success'); + expect(runData?.[2].executionTime).toBe(100); + }); + + it('should not modify execution data if there is no matched index in execution data', () => { + workflowsStore.updateNodeExecutionRunData({ + executionId: 'test-execution', + nodeName: 'n0', + data: createTestTaskData({ + executionIndex: 3, + executionStatus: 'success', + executionTime: 100, + }), + itemCountByConnectionType: { main: [1] }, + }); + + const runData = workflowsStore.workflowExecutionData?.data?.resultData?.runData.n0; + + expect(runData).toHaveLength(3); + expect(runData?.[0].executionIndex).toBe(0); + expect(runData?.[0].executionStatus).toBe('success'); + expect(runData?.[0].executionTime).toBe(33); + expect(runData?.[1].executionIndex).toBe(1); + expect(runData?.[1].executionStatus).toBe('success'); + expect(runData?.[1].executionTime).toBe(44); + expect(runData?.[2].executionIndex).toBe(2); + expect(runData?.[2].executionStatus).toBe('running'); + expect(runData?.[2].executionTime).toBe(undefined); + }); + }); + + describe('updateNodeExecutionStatus', () => { let successEvent: ReturnType['successEvent']; let errorEvent: ReturnType['errorEvent']; let executionResponse: ReturnType['executionResponse']; @@ -670,7 +759,7 @@ describe('useWorkflowsStore', () => { }); it('should throw error if not initialized', () => { - expect(() => workflowsStore.updateNodeExecutionData(successEvent)).toThrowError(); + expect(() => workflowsStore.updateNodeExecutionStatus(successEvent)).toThrowError(); }); it('should add node success run data', () => { @@ -681,7 +770,7 @@ describe('useWorkflowsStore', () => { }); // ACT - workflowsStore.updateNodeExecutionData(successEvent); + workflowsStore.updateNodeExecutionStatus(successEvent); expect(workflowsStore.workflowExecutionData).toEqual({ ...executionResponse, @@ -710,7 +799,7 @@ describe('useWorkflowsStore', () => { getNodeType.mockReturnValue(getMockEditFieldsNode()); // ACT - workflowsStore.updateNodeExecutionData(errorEvent); + workflowsStore.updateNodeExecutionStatus(errorEvent); await flushPromises(); expect(workflowsStore.workflowExecutionData).toEqual({ @@ -793,7 +882,7 @@ describe('useWorkflowsStore', () => { }); // ACT - workflowsStore.updateNodeExecutionData(successEvent); + workflowsStore.updateNodeExecutionStatus(successEvent); expect(workflowsStore.workflowExecutionData).toEqual({ ...runWithExistingRunData, @@ -806,6 +895,7 @@ describe('useWorkflowsStore', () => { }, }); }); + it('should replace existing placeholder task data in new log view', () => { const successEventWithExecutionIndex = deepCopy(successEvent); successEventWithExecutionIndex.data.executionIndex = 1; @@ -846,7 +936,7 @@ describe('useWorkflowsStore', () => { }); // ACT - workflowsStore.updateNodeExecutionData(successEventWithExecutionIndex); + workflowsStore.updateNodeExecutionStatus(successEventWithExecutionIndex); expect(workflowsStore.workflowExecutionData).toEqual({ ...executionResponse, @@ -1497,6 +1587,68 @@ describe('useWorkflowsStore', () => { await waitFor(() => expect(workflowsStore.selectedTriggerNodeName).toBe(undefined)); }); }); + + describe('markExecutionAsStopped', () => { + beforeEach(() => { + workflowsStore.workflowExecutionData = createTestWorkflowExecutionResponse({ + status: 'running', + startedAt: new Date('2023-01-01T09:00:00Z'), + stoppedAt: undefined, + data: { + resultData: { + runData: { + node1: [ + createTestTaskData({ executionStatus: 'success' }), + createTestTaskData({ executionStatus: 'error' }), + createTestTaskData({ executionStatus: 'running' }), + ], + node2: [ + createTestTaskData({ executionStatus: 'success' }), + createTestTaskData({ executionStatus: 'waiting' }), + ], + }, + }, + }, + }); + }); + + it('should remove non successful node runs', () => { + workflowsStore.markExecutionAsStopped(); + + const runData = workflowsStore.workflowExecutionData?.data?.resultData?.runData; + expect(runData?.node1).toHaveLength(1); + expect(runData?.node1[0].executionStatus).toBe('success'); + expect(runData?.node2).toHaveLength(1); + expect(runData?.node2[0].executionStatus).toBe('success'); + }); + + it('should update execution status, startedAt and stoppedAt when data is provided', () => { + workflowsStore.markExecutionAsStopped({ + status: 'canceled', + startedAt: new Date('2023-01-01T10:00:00Z'), + stoppedAt: new Date('2023-01-01T10:05:00Z'), + mode: 'manual', + }); + + expect(workflowsStore.workflowExecutionData?.status).toBe('canceled'); + expect(workflowsStore.workflowExecutionData?.startedAt).toEqual( + new Date('2023-01-01T10:00:00Z'), + ); + expect(workflowsStore.workflowExecutionData?.stoppedAt).toEqual( + new Date('2023-01-01T10:05:00Z'), + ); + }); + + it('should not update execution data when stopData is not provided', () => { + workflowsStore.markExecutionAsStopped(); + + expect(workflowsStore.workflowExecutionData?.status).toBe('running'); + expect(workflowsStore.workflowExecutionData?.startedAt).toEqual( + new Date('2023-01-01T09:00:00Z'), + ); + expect(workflowsStore.workflowExecutionData?.stoppedAt).toBeUndefined(); + }); + }); }); function getMockEditFieldsNode(): Partial { diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts index 221315eff70..a10400d379e 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts @@ -28,6 +28,7 @@ import type { NodeMetadataMap, IExecutionFlattedResponse, WorkflowListResource, + IExecutionsStopData, } from '@/Interface'; import type { IWorkflowTemplateNode } from '@n8n/rest-api-client/api/templates'; import type { @@ -1556,7 +1557,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { ]; } - function updateNodeExecutionData(pushData: PushPayload<'nodeExecuteAfterData'>): void { + function updateNodeExecutionStatus(pushData: PushPayload<'nodeExecuteAfterData'>): void { if (!workflowExecutionData.value?.data) { throw new Error('The "workflowExecutionData" is not initialized!'); } @@ -1583,7 +1584,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { openFormPopupWindow(testUrl); } } else { - // If we process items in paralell on subnodes we get several placeholder taskData items. + // If we process items in parallel on subnodes we get several placeholder taskData items. // We need to find and replace the item with the matching executionIndex and only append if we don't find anything matching. const existingRunIndex = tasksData.findIndex( (item) => item.executionIndex === data.executionIndex, @@ -1607,6 +1608,17 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { } } + function updateNodeExecutionRunData(pushData: PushPayload<'nodeExecuteAfterData'>): void { + const tasksData = workflowExecutionData.value?.data?.resultData.runData[pushData.nodeName]; + const existingRunIndex = + tasksData?.findIndex((item) => item.executionIndex === pushData.data.executionIndex) ?? -1; + + if (tasksData?.[existingRunIndex]) { + tasksData.splice(existingRunIndex, 1, pushData.data); + workflowExecutionResultDataLastUpdate.value = Date.now(); + } + } + function clearNodeExecutionData(nodeName: string): void { if (!workflowExecutionData.value?.data) { return; @@ -1897,20 +1909,32 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { // End Canvas V2 Functions // - function markExecutionAsStopped() { + function markExecutionAsStopped(stopData?: IExecutionsStopData) { setActiveExecutionId(undefined); clearNodeExecutionQueue(); executionWaitingForWebhook.value = false; workflowHelpers.setDocumentTitle(workflowName.value, 'IDLE'); + workflowExecutionStartedData.value = undefined; clearPopupWindowState(); - const runData = workflowExecutionData.value?.data?.resultData.runData ?? {}; + if (!workflowExecutionData.value) { + return; + } + + const runData = workflowExecutionData.value.data?.resultData.runData ?? {}; + for (const nodeName in runData) { runData[nodeName] = runData[nodeName].filter( ({ executionStatus }) => executionStatus === 'success', ); } + + if (stopData) { + workflowExecutionData.value.status = stopData.status; + workflowExecutionData.value.startedAt = stopData.startedAt; + workflowExecutionData.value.stoppedAt = stopData.stoppedAt; + } } function setSelectedTriggerNodeName(value: string) { @@ -2077,7 +2101,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { setNodeValue, setNodeParameters, setLastNodeParameters, - updateNodeExecutionData, + updateNodeExecutionRunData, + updateNodeExecutionStatus, clearNodeExecutionData, pinDataByNodeName, activeNode, diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index d5c143a9384..7111604820f 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -446,6 +446,10 @@ export class CanvasPage extends BasePage { return this.page.getByTestId('toggle-focus-panel-button'); } + stopExecutionButton(): Locator { + return this.page.getByTestId('stop-execution-button'); + } + // Actions async addInitialNodeToCanvas(nodeName: string): Promise { diff --git a/packages/testing/playwright/tests/ui/50-logs.spec.ts b/packages/testing/playwright/tests/ui/50-logs.spec.ts index f4e32d37a6d..dada10b3912 100644 --- a/packages/testing/playwright/tests/ui/50-logs.spec.ts +++ b/packages/testing/playwright/tests/ui/50-logs.spec.ts @@ -250,6 +250,9 @@ test.describe('Logs', () => { .getAttribute('href'); await n8n.ndv.clickBackToCanvasButton(); + // [CAT-1454] Assert that no duplicate logs added at this point + await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(2); + // Trigger the webhook const response = await n8n.page.request.get(webhookUrl!); expect(response.status()).toBe(200); @@ -263,4 +266,27 @@ test.describe('Logs', () => { await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.WAIT_NODE); await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText('Success'); }); + + test('should allow to cancel a workflow with a node that waits for webhook', async ({ n8n }) => { + await n8n.start.fromImportedWorkflow('Workflow_wait_for_webhook.json'); + await n8n.canvas.deselectAll(); + await n8n.canvas.logsPanel.open(); + + await n8n.canvas.clickExecuteWorkflowButton(); + + await expect(n8n.canvas.getWaitingNodes()).toContainText(NODES.WAIT_NODE); + await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(2); + await expect(n8n.canvas.logsPanel.getLogEntries().nth(0)).toContainText( + 'When clicking ‘Test workflow’', + ); + await expect(n8n.canvas.logsPanel.getLogEntries().nth(1)).toContainText(NODES.WAIT_NODE); + + await n8n.canvas.stopExecutionButton().click(); + await expect(n8n.canvas.stopExecutionButton()).toBeHidden(); + await expect(n8n.canvas.logsPanel.getOverviewStatus()).toContainText('Canceled in'); + await expect(n8n.canvas.logsPanel.getLogEntries()).toHaveCount(1); + await expect(n8n.canvas.logsPanel.getLogEntries().nth(0)).toContainText( + 'When clicking ‘Test workflow’', + ); + }); });