diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 3958d5a7e6..039515a65d 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -44,6 +44,7 @@ import { NodeOperationError, Workflow, } from 'n8n-workflow'; +import assert from 'node:assert'; import * as Helpers from '@test/helpers'; import { legacyWorkflowExecuteTests, v1WorkflowExecuteTests } from '@test/helpers/constants'; @@ -2485,4 +2486,87 @@ describe('WorkflowExecute', () => { ]); }); }); + + describe('Cancellation', () => { + test('should update only running task statuses to cancelled when workflow is cancelled', () => { + // Arrange - create a workflow with some nodes + const startNode = createNodeData({ name: 'Start' }); + const processingNode = createNodeData({ name: 'Processing' }); + const completedNode = createNodeData({ name: 'Completed' }); + + const workflow = new Workflow({ + id: 'test-workflow', + nodes: [startNode, processingNode, completedNode], + connections: {}, + active: false, + nodeTypes, + }); + + const waitPromise = createDeferredPromise(); + const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise); + const workflowExecute = new WorkflowExecute(additionalData, 'manual'); + + // Create run execution data with tasks in various statuses + const runExecutionData: IRunExecutionData = { + startData: { startNodes: [{ name: 'Start', sourceData: null }] }, + resultData: { + runData: { + Start: [toITaskData([{ data: { test: 'data' } }], { executionStatus: 'success' })], + Processing: [toITaskData([{ data: { test: 'data' } }], { executionStatus: 'running' })], + Completed: [ + toITaskData([{ data: { test: 'data1' } }], { executionStatus: 'error' }), + toITaskData([{ data: { test: 'data2' } }], { executionStatus: 'running' }), + toITaskData([{ data: { test: 'data3' } }], { executionStatus: 'waiting' }), + ], + }, + lastNodeExecuted: 'Processing', + }, + executionData: mock({ + nodeExecutionStack: [], + metadata: {}, + }), + }; + + // Set the run execution data on the workflow execute instance + // @ts-expect-error private data + workflowExecute.runExecutionData = runExecutionData; + + assert(additionalData.hooks); + const runHook = jest.fn(); + additionalData.hooks.runHook = runHook; + + const promise = workflowExecute.processRunExecutionData(workflow); + promise.cancel('reason'); + + const updatedExecutionData = { + data: { + startData: { startNodes: [{ name: 'Start', sourceData: null }] }, + resultData: { + runData: { + Start: [toITaskData([{ data: { test: 'data' } }], { executionStatus: 'success' })], + Processing: [ + toITaskData([{ data: { test: 'data' } }], { executionStatus: 'canceled' }), + ], + Completed: [ + toITaskData([{ data: { test: 'data1' } }], { executionStatus: 'error' }), + toITaskData([{ data: { test: 'data2' } }], { executionStatus: 'canceled' }), + toITaskData([{ data: { test: 'data3' } }], { executionStatus: 'waiting' }), + ], + }, + lastNodeExecuted: 'Processing', + }, + executionData: mock({ + nodeExecutionStack: [], + metadata: {}, + }), + }, + }; + + expect(runHook.mock.lastCall[0]).toEqual('workflowExecuteAfter'); + expect(JSON.stringify(runHook.mock.lastCall[1][0].data)).toBe( + JSON.stringify(updatedExecutionData.data), + ); + expect(runHook.mock.lastCall[1][0].status).toEqual('canceled'); + }); + }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts index 37d2f3272d..058ea0fdc6 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts @@ -16,7 +16,7 @@ import type { NodeConnectionType, IRunData, } from 'n8n-workflow'; -import { ApplicationError, NodeConnectionTypes } from 'n8n-workflow'; +import { ApplicationError, ExecutionCancelledError, NodeConnectionTypes } from 'n8n-workflow'; import { describeCommonTests } from './shared-tests'; import { SupplyDataContext } from '../supply-data-context'; @@ -219,4 +219,70 @@ describe('SupplyDataContext', () => { expect(latestRunIndex).toBe(2); }); }); + + describe('addExecutionDataFunctions', () => { + it('should preserve canceled status when execution is aborted and output has error', async () => { + const errorData = new ExecutionCancelledError('Execution was aborted'); + const abortedSignal = mock({ aborted: true }); + const mockHooks = { + runHook: jest.fn().mockResolvedValue(undefined), + }; + const testAdditionalData = mock({ + credentialsHelper, + hooks: mockHooks, + currentNodeExecutionIndex: 0, + }); + const testRunExecutionData = mock({ + resultData: { + runData: { + [node.name]: [ + { + executionStatus: 'canceled', + startTime: Date.now(), + executionTime: 0, + executionIndex: 0, + error: undefined, + }, + ], + }, + error: undefined, + }, + executionData: { metadata: {} }, + }); + + const contextWithAbort = new SupplyDataContext( + workflow, + node, + testAdditionalData, + mode, + testRunExecutionData, + runIndex, + connectionInputData, + inputData, + 'ai_agent', + executeData, + [closeFn], + abortedSignal, + ); + + await contextWithAbort.addExecutionDataFunctions( + 'output', + errorData, + 'ai_agent', + node.name, + 0, + ); + + const taskData = testRunExecutionData.resultData.runData[node.name][0]; + expect(taskData.executionStatus).toBe('canceled'); + expect(taskData.error).toBeUndefined(); + + // Verify nodeExecuteAfter hook was called correctly + expect(mockHooks.runHook).toHaveBeenCalledWith('nodeExecuteAfter', [ + node.name, + taskData, + testRunExecutionData, + ]); + }); + }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts b/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts index e60ac53f3a..7663a48870 100644 --- a/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/supply-data-context.ts @@ -280,8 +280,14 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData taskData = taskData!; if (data instanceof Error) { - taskData.executionStatus = 'error'; - taskData.error = data; + // if running node was already marked as "canceled" because execution was aborted + // leave as "canceled" instead of showing "This operation was aborted" error + if ( + !(type === 'output' && this.abortSignal?.aborted && taskData.executionStatus === 'canceled') + ) { + taskData.executionStatus = 'error'; + taskData.error = data; + } } else { if (type === 'output') { taskData.executionStatus = 'success'; diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index dd6de7bc2c..ee74f14860 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -1584,6 +1584,7 @@ export class WorkflowExecute { onCancel.shouldReject = false; onCancel(() => { this.status = 'canceled'; + this.updateTaskStatusesToCancelled(); this.abortController.abort(); const fullRunData = this.getFullRunData(startedAt); void hooks.runHook('workflowExecuteAfter', [fullRunData]); @@ -2654,6 +2655,17 @@ export class WorkflowExecute { return nodeSuccessData; } + private updateTaskStatusesToCancelled(): void { + Object.keys(this.runExecutionData.resultData.runData).forEach((nodeName) => { + const taskDataArray = this.runExecutionData.resultData.runData[nodeName]; + taskDataArray.forEach((taskData) => { + if (taskData.executionStatus === 'running') { + taskData.executionStatus = 'canceled'; + } + }); + }); + } + private get isCancelled() { return this.abortController.signal.aborted; } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e3cff390fe..21dd651566 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1883,6 +1883,7 @@ "runData.editValue": "Edit Value", "runData.executionStatus.success": "Executed successfully", "runData.executionStatus.failed": "Execution failed", + "runData.executionStatus.canceled": "Execution canceled", "runData.downloadBinaryData": "Download", "runData.executeNode": "Test Node", "runData.executionTime": "Execution Time", diff --git a/packages/frontend/editor-ui/src/components/RunInfo.test.ts b/packages/frontend/editor-ui/src/components/RunInfo.test.ts new file mode 100644 index 0000000000..7446f414fd --- /dev/null +++ b/packages/frontend/editor-ui/src/components/RunInfo.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { ITaskData } from 'n8n-workflow'; +import RunInfo from './RunInfo.vue'; +import { createComponentRenderer } from '@/__tests__/render'; +import { mock } from 'vitest-mock-extended'; + +vi.mock('@/utils/formatters/dateFormatter', () => ({ + convertToDisplayDateComponents: vi.fn(() => ({ + date: 'Jan 15', + time: '10:30:00', + })), +})); + +vi.mock('@n8n/i18n', async (importOriginal) => { + return { + ...(await importOriginal()), + useI18n: () => ({ + baseText: vi.fn((key: string) => { + const translations: Record = { + 'runData.executionStatus.success': 'Success', + 'runData.executionStatus.canceled': 'Canceled', + 'runData.executionStatus.failed': 'Failed', + 'runData.startTime': 'Start time', + 'runData.executionTime': 'Execution time', + 'runData.ms': 'ms', + }; + return translations[key] || key; + }), + }), + }; +}); + +const renderComponent = createComponentRenderer(RunInfo); + +describe('RunInfo', () => { + it('should display success status when execution status is success', () => { + const successTaskData: ITaskData = mock({ + startTime: Date.now(), + executionTime: 1500, + executionStatus: 'success', + data: {}, + error: undefined, + }); + + const { getByTestId, container } = renderComponent({ + props: { + taskData: successTaskData, + hasStaleData: false, + hasPinData: false, + }, + }); + + expect(getByTestId('node-run-status-success')).toBeInTheDocument(); + expect(getByTestId('node-run-info')).toBeInTheDocument(); + + const tooltipDiv = container.querySelector('.tooltipRow'); + expect(tooltipDiv).toBeInTheDocument(); + + const statusIcon = getByTestId('node-run-status-success'); + expect(statusIcon).toHaveClass('success'); + + const infoIcon = getByTestId('node-run-info'); + expect(infoIcon).toBeInTheDocument(); + + // Verify the component renders with success status + expect(statusIcon).toHaveAttribute('data-test-id', 'node-run-status-success'); + + // Check tooltip content exists in the DOM (even if hidden) + expect(document.body).toHaveTextContent('Success'); + expect(document.body).toHaveTextContent('Start time:'); + expect(document.body).toHaveTextContent('Jan 15 at 10:30:00'); + expect(document.body).toHaveTextContent('Execution time:'); + expect(document.body).toHaveTextContent('1500 ms'); + }); + + it('should display cancelled status when execution status is canceled', () => { + const cancelledTaskData: ITaskData = mock({ + startTime: 1757506978099, + executionTime: 800, + executionStatus: 'canceled', + data: {}, + error: undefined, + }); + + const { getByTestId, container, queryByTestId } = renderComponent({ + props: { + taskData: cancelledTaskData, + hasStaleData: false, + hasPinData: false, + }, + }); + + expect(queryByTestId('node-run-status-success')).not.toBeInTheDocument(); + expect(getByTestId('node-run-info')).toBeInTheDocument(); + + const tooltipDiv = container.querySelector('.tooltipRow'); + expect(tooltipDiv).toBeInTheDocument(); + + const infoIcon = getByTestId('node-run-info'); + expect(infoIcon).toBeInTheDocument(); + + // For cancelled status, only info tooltip is shown (no status icon) + expect(infoIcon).toHaveAttribute('data-test-id', 'node-run-info'); + + // Check tooltip content exists in the DOM (even if hidden) + expect(document.body).toHaveTextContent('Canceled'); + expect(document.body).toHaveTextContent('Start time:'); + expect(document.body).toHaveTextContent('Jan 15 at 10:30:00'); + expect(document.body).toHaveTextContent('Execution time:'); + expect(document.body).toHaveTextContent('800 ms'); + }); + + it('should display error status when there is an error', () => { + const errorTaskData: ITaskData = mock({ + startTime: 1757506978099, + executionTime: 1200, + executionStatus: 'success', + data: {}, + error: { + message: 'Something went wrong', + name: 'Error', + }, + }); + + const { getByTestId, container } = renderComponent({ + props: { + taskData: errorTaskData, + hasStaleData: false, + hasPinData: false, + }, + }); + + expect(getByTestId('node-run-status-danger')).toBeInTheDocument(); + expect(getByTestId('node-run-info')).toBeInTheDocument(); + + const tooltipDiv = container.querySelector('.tooltipRow'); + expect(tooltipDiv).toBeInTheDocument(); + + const statusIcon = getByTestId('node-run-status-danger'); + expect(statusIcon).toHaveClass('danger'); + + const infoIcon = getByTestId('node-run-info'); + expect(infoIcon).toBeInTheDocument(); + + // Verify the component renders with error status + expect(statusIcon).toHaveAttribute('data-test-id', 'node-run-status-danger'); + + // Check tooltip content exists in the DOM (even if hidden) + expect(document.body).toHaveTextContent('Failed'); + expect(document.body).toHaveTextContent('Start time:'); + expect(document.body).toHaveTextContent('Jan 15 at 10:30:00'); + expect(document.body).toHaveTextContent('Execution time:'); + expect(document.body).toHaveTextContent('1200 ms'); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/RunInfo.vue b/packages/frontend/editor-ui/src/components/RunInfo.vue index 5f6170a0aa..d92c6c1ed0 100644 --- a/packages/frontend/editor-ui/src/components/RunInfo.vue +++ b/packages/frontend/editor-ui/src/components/RunInfo.vue @@ -53,6 +53,7 @@ const runMetadata = computed(() => {
{ >{{ runTaskData?.error ? i18n.baseText('runData.executionStatus.failed') - : i18n.baseText('runData.executionStatus.success') + : runTaskData?.executionStatus === 'canceled' + ? i18n.baseText('runData.executionStatus.canceled') + : i18n.baseText('runData.executionStatus.success') }}
{{ diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue index 4c9aec478c..0606b456b8 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -53,7 +53,7 @@ const classes = computed(() => { [$style.node]: true, [$style.selected]: isSelected.value, [$style.disabled]: isDisabled.value, - [$style.success]: hasRunData.value, + [$style.success]: hasRunData.value && executionStatus.value === 'success', [$style.error]: hasExecutionErrors.value, [$style.pinned]: hasPinnedData.value, [$style.waiting]: executionWaiting.value ?? executionStatus.value === 'waiting', diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts index 9afe102ece..4b22556169 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts @@ -75,7 +75,10 @@ describe('CanvasNodeStatusIcons', () => { provide: { ...createCanvasProvide(), ...createCanvasNodeProvide({ - data: { runData: { outputMap: {}, iterations: 15, visible: true } }, + data: { + execution: { status: 'success', running: false }, + runData: { outputMap: {}, iterations: 15, visible: true }, + }, }), }, }, @@ -84,6 +87,24 @@ describe('CanvasNodeStatusIcons', () => { expect(getByTestId('canvas-node-status-success')).toHaveTextContent('15'); }); + it('should not render success icon for a node that was canceled', () => { + const { queryByTestId } = renderComponent({ + global: { + provide: { + ...createCanvasProvide(), + ...createCanvasNodeProvide({ + data: { + execution: { status: 'canceled', running: false }, + runData: { outputMap: {}, iterations: 15, visible: true }, + }, + }), + }, + }, + }); + + expect(queryByTestId('canvas-node-status-success')).not.toBeInTheDocument(); + }); + it('should render correctly for a dirty node that has run successfully', () => { const { getByTestId } = renderComponent({ global: { diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue index 5236b0e6d1..53f24f9a5b 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue @@ -131,7 +131,7 @@ const commonClasses = computed(() => [
diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts index bc232bb10e..79729c3360 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.test.ts @@ -567,6 +567,116 @@ describe('useCanvasMapping', () => { }, }); }); + + it('should not count canceled iterations but still count their data', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodes = [createTestNode({ name: 'Node 1' })]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }, { json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 2, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]); + + const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(nodeExecutionRunDataOutputMapById.value).toEqual({ + [nodes[0].id]: { + [NodeConnectionTypes.Main]: { + 0: { + iterations: 2, // Only 2 iterations counted (not the canceled one) + total: 6, // All data items still counted + }, + }, + }, + }); + }); + + it('should handle all canceled iterations correctly', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodes = [createTestNode({ name: 'Node 1' })]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }, { json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]); + + const { nodeExecutionRunDataOutputMapById } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(nodeExecutionRunDataOutputMapById.value).toEqual({ + [nodes[0].id]: { + [NodeConnectionTypes.Main]: { + 0: { + iterations: 0, // No iterations counted since all are canceled + total: 3, // But data items still counted + }, + }, + }, + }); + }); }); describe('additionalNodePropertiesById', () => { @@ -961,6 +1071,262 @@ describe('useCanvasMapping', () => { }); }); + describe('filterOutCanceled helper function', () => { + it('should return null for null input', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodes = [createTestNode({ name: 'Node 1' })]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ nodes, connections }); + + workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue(null); + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(0); + }); + + it('should filter out canceled tasks and return correct iteration count', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodes = [createTestNode({ name: 'Node 1' })]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ nodes, connections }); + + workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 2, + source: [], + executionStatus: 'error', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]); + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(2); + expect(mappedNodes.value[0]?.data?.runData?.visible).toEqual(true); + }); + + it('should return 0 iterations when all tasks are canceled', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodes = [createTestNode({ name: 'Node 1' })]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ nodes, connections }); + + workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]); + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(0); + expect(mappedNodes.value[0]?.data?.runData?.visible).toEqual(true); + }); + + it('should return correct count when no canceled tasks', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodes = [createTestNode({ name: 'Node 1' })]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ nodes, connections }); + + workflowsStore.getWorkflowResultDataByNodeName.mockReturnValue([ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [], + executionStatus: 'error', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]); + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedNodes.value[0]?.data?.runData?.iterations).toEqual(2); + }); + }); + + describe('nodeExecutionStatusById', () => { + it('should return last execution status when not canceled', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const node = createTestNode({ name: 'Test Node' }); + const nodes = [node]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ nodes, connections }); + + workflowsStore.getWorkflowRunData = { + 'Test Node': [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + }, + ], + }; + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('success'); + }); + + it('should return second-to-last status when last execution is canceled and multiple tasks exist', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const node = createTestNode({ name: 'Test Node' }); + const nodes = [node]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ nodes, connections }); + + workflowsStore.getWorkflowRunData = { + 'Test Node': [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [], + executionStatus: 'canceled', + }, + ], + }; + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('success'); + }); + + it('should return canceled status when only one task exists and it is canceled', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const node = createTestNode({ name: 'Test Node' }); + const nodes = [node]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ nodes, connections }); + + workflowsStore.getWorkflowRunData = { + 'Test Node': [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'canceled', + }, + ], + }; + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('canceled'); + }); + + it('should return new status when no tasks exist', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const node = createTestNode({ name: 'Test Node' }); + const nodes = [node]; + const connections = {}; + const workflowObject = createTestWorkflowObject({ nodes, connections }); + + workflowsStore.getWorkflowRunData = {}; + + const { nodes: mappedNodes } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedNodes.value[0]?.data?.execution?.status).toEqual('new'); + }); + }); + describe('nodeHasIssuesById', () => { it('should return false when node has no issues or errors', () => { const workflowsStore = mockedStore(useWorkflowsStore); @@ -1503,5 +1869,203 @@ describe('useCanvasMapping', () => { }, ]); }); + + describe('connection status with canceled tasks', () => { + it('should not set success status when last task is canceled', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); + const nodes = [manualTriggerNode, setNode]; + const connections = { + [manualTriggerNode.name]: { + [NodeConnectionTypes.Main]: [ + [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === manualTriggerNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } + return null; + }); + + const { connections: mappedConnections } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); + }); + + it('should set success status when last task is canceled but previous is success', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); + const nodes = [manualTriggerNode, setNode]; + const connections = { + [manualTriggerNode.name]: { + [NodeConnectionTypes.Main]: [ + [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === manualTriggerNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } + return null; + }); + + const { connections: mappedConnections } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedConnections.value[0]?.data?.status).toEqual('success'); + }); + + it('should handle connection with only canceled tasks', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); + const nodes = [manualTriggerNode, setNode]; + const connections = { + [manualTriggerNode.name]: { + [NodeConnectionTypes.Main]: [ + [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === manualTriggerNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + { + startTime: 0, + executionTime: 0, + executionIndex: 1, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } + return null; + }); + + const { connections: mappedConnections } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedConnections.value[0]?.data?.status).toBeUndefined(); + }); + + it('should prioritize running status over canceled task handling', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const [manualTriggerNode, setNode] = mockNodes.slice(0, 2); + const nodes = [manualTriggerNode, setNode]; + const connections = { + [manualTriggerNode.name]: { + [NodeConnectionTypes.Main]: [ + [{ node: setNode.name, type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }; + const workflowObject = createTestWorkflowObject({ + nodes, + connections, + }); + + workflowsStore.isNodeExecuting.mockImplementation((nodeName: string) => { + return nodeName === manualTriggerNode.name; + }); + + workflowsStore.getWorkflowResultDataByNodeName.mockImplementation((nodeName: string) => { + if (nodeName === manualTriggerNode.name) { + return [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + executionStatus: 'canceled', + data: { + [NodeConnectionTypes.Main]: [[{ json: {} }]], + }, + }, + ]; + } + return null; + }); + + const { connections: mappedConnections } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + workflowObject: ref(workflowObject) as Ref, + }); + + expect(mappedConnections.value[0]?.data?.status).toEqual('running'); + }); + }); }); }); diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts index 6d45e350ab..f20a47d583 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts @@ -345,7 +345,11 @@ export function useCanvasMapping({ nodes.value.reduce>((acc, node) => { const tasks = workflowsStore.getWorkflowRunData?.[node.name] ?? []; - acc[node.id] = tasks.at(-1)?.executionStatus ?? 'new'; + let lastExecutionStatus = tasks.at(-1)?.executionStatus; + if (tasks.length > 1 && lastExecutionStatus === 'canceled') { + lastExecutionStatus = tasks.at(-2)?.executionStatus; + } + acc[node.id] = lastExecutionStatus ?? 'new'; return acc; }, {}), ); @@ -382,7 +386,9 @@ export function useCanvasMapping({ acc[nodeId][connectionType][outputIndex] = acc[nodeId][connectionType][ outputIndex ] ?? { ...outputData }; - acc[nodeId][connectionType][outputIndex].iterations += 1; + if (runIteration.executionStatus !== 'canceled') { + acc[nodeId][connectionType][outputIndex].iterations += 1; + } acc[nodeId][connectionType][outputIndex].total += connectionTypeOutputIndexData.length; } @@ -593,6 +599,14 @@ export function useCanvasMapping({ }, {}); }); + function filterOutCanceled(tasks: ITaskData[] | null): ITaskData[] | null { + if (!tasks) { + return null; + } + + return tasks.filter((task) => task.executionStatus !== 'canceled'); + } + const mappedNodes = computed(() => { const connectionsBySourceNode = connections.value; const connectionsByDestinationNode = @@ -635,7 +649,7 @@ export function useCanvasMapping({ }, runData: { outputMap: nodeExecutionRunDataOutputMapById.value[node.id], - iterations: nodeExecutionRunDataById.value[node.id]?.length ?? 0, + iterations: filterOutCanceled(nodeExecutionRunDataById.value[node.id])?.length ?? 0, visible: !!nodeExecutionRunDataById.value[node.id], }, render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} }, @@ -677,6 +691,12 @@ export function useCanvasMapping({ const runDataTotal = nodeExecutionRunDataOutputMapById.value[connection.source]?.[type]?.[index]?.total ?? 0; + const sourceTasks = nodeExecutionRunDataById.value[connection.source] ?? []; + let lastSourceTask: ITaskData | undefined = sourceTasks[sourceTasks.length - 1]; + if (lastSourceTask?.executionStatus === 'canceled' && sourceTasks.length > 1) { + lastSourceTask = sourceTasks[sourceTasks.length - 2]; + } + let status: CanvasConnectionData['status']; if (nodeExecutionRunningById.value[connection.source]) { status = 'running'; @@ -687,7 +707,7 @@ export function useCanvasMapping({ status = 'pinned'; } else if (nodeHasIssuesById.value[connection.source]) { status = 'error'; - } else if (runDataTotal > 0) { + } else if (runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled') { status = 'success'; }