From e68149bbc7760b2c1f311803f51588fbb0704fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Fri, 16 May 2025 10:53:55 +0200 Subject: [PATCH] fix(editor): Show error toast for failed executions (#15388) --- .../composables/useCanvasOperations.test.ts | 49 +++++++ .../src/composables/useCanvasOperations.ts | 9 ++ .../handlers/executionFinished.ts | 130 +++-------------- .../src/utils/executionUtils.test.ts | 135 +++++++++++++++++- .../editor-ui/src/utils/executionUtils.ts | 76 ++++++++++ 5 files changed, 289 insertions(+), 110 deletions(-) diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts index 366e6731cf9..2fac7269623 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.test.ts @@ -53,6 +53,7 @@ import { nextTick } from 'vue'; import { useProjectsStore } from '@/stores/projects.store'; import type { CanvasLayoutEvent } from './useCanvasLayout'; import { useTelemetry } from './useTelemetry'; +import { useToast } from '@/composables/useToast'; vi.mock('vue-router', async (importOriginal) => { const actual = await importOriginal<{}>(); @@ -88,6 +89,21 @@ vi.mock('@/composables/useTelemetry', () => { }; }); +vi.mock('@/composables/useToast', () => { + const showMessage = vi.fn(); + const showError = vi.fn(); + const showToast = vi.fn(); + return { + useToast: () => { + return { + showMessage, + showError, + showToast, + }; + }, + }; +}); + describe('useCanvasOperations', () => { const router = useRouter(); @@ -2726,6 +2742,39 @@ describe('useCanvasOperations', () => { expect(workflowsStore.setWorkflowPinData).toHaveBeenCalledWith({}); }); + it('should show an error notification for failed executions', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const { openExecution } = useCanvasOperations({ router }); + const toast = useToast(); + + const executionId = '123'; + const executionData: IExecutionResponse = { + id: executionId, + finished: true, + status: 'error', + startedAt: new Date(), + createdAt: new Date(), + workflowData: createTestWorkflow(), + mode: 'manual', + data: { + resultData: { + error: { message: 'Crashed', node: { name: 'Step1' } }, + lastNodeExecuted: 'Last Node', + }, + } as IExecutionResponse['data'], + }; + + workflowsStore.getExecution.mockResolvedValue(executionData); + + await openExecution(executionId); + + expect(toast.showMessage).toHaveBeenCalledWith({ + duration: 0, + message: 'Crashed', + title: 'Problem in node ‘Last Node‘', + type: 'error', + }); + }); }); describe('connectAdjacentNodes', () => { diff --git a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts index 6d4928d34cb..a354321389d 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasOperations.ts @@ -24,6 +24,7 @@ import { type PinDataSource, usePinnedData } from '@/composables/usePinnedData'; import { useTelemetry } from '@/composables/useTelemetry'; import { useToast } from '@/composables/useToast'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; +import { getExecutionErrorToastConfiguration } from '@/utils/executionUtils'; import { EnterpriseEditionFeature, FORM_TRIGGER_NODE_TYPE, @@ -2009,6 +2010,14 @@ export function useCanvasOperations({ router }: { router: ReturnType ({ vi.mock('@/plugins/i18n', () => ({ i18n: { - baseText: (key: string) => { + baseText: (key: string, options?: { interpolate?: { error?: string; details?: string } }) => { const texts: { [key: string]: string } = { 'ndv.output.waitNodeWaiting': 'Waiting for execution to resume...', 'ndv.output.waitNodeWaitingForFormSubmission': 'Waiting for form submission: ', 'ndv.output.waitNodeWaitingForWebhook': 'Waiting for webhook call: ', 'ndv.output.githubNodeWaitingForWebhook': 'Waiting for webhook call: ', 'ndv.output.sendAndWaitWaitingApproval': 'Waiting for approval...', + 'pushConnection.executionError': `Execution error${options?.interpolate?.error}`, + 'pushConnection.executionError.details': `Details: ${options?.interpolate?.details}`, }; return texts[key] || key; }, @@ -323,3 +332,123 @@ describe('waitingNodeTooltip', () => { ); }); }); + +const executionErrorFactory = (error: Record) => + error as unknown as ExecutionError; + +describe('getExecutionErrorMessage', () => { + it('returns error.message when lastNodeExecuted and error are present', () => { + const result = getExecutionErrorMessage({ + error: executionErrorFactory({ message: 'Node failed' }), + lastNodeExecuted: 'Node1', + }); + expect(result).toBe('Node failed'); + }); + + it('uses fallback translation when only error.message is provided', () => { + const result = getExecutionErrorMessage({ + error: executionErrorFactory({ message: 'Something went wrong' }), + }); + expect(result).toBe('Execution error.Details: Something went wrong'); + }); + + it('includes node name if error.node is a string', () => { + const result = getExecutionErrorMessage({ + error: executionErrorFactory({ message: 'Failed', node: 'MyNode' }), + }); + expect(result).toBe('Execution error.Details: MyNode: Failed'); + }); + + it('includes node.name if error.node is an object', () => { + const result = getExecutionErrorMessage({ + error: executionErrorFactory({ message: 'Crashed', node: { name: 'Step1' } }), + }); + expect(result).toBe('Execution error.Details: Step1: Crashed'); + }); + + it('uses default fallback when no error or lastNodeExecuted', () => { + const result = getExecutionErrorMessage({}); + expect(result).toBe('Execution error!'); + }); +}); + +describe('getExecutionErrorToastConfiguration', () => { + it('returns config for SubworkflowOperationError', () => { + const result = getExecutionErrorToastConfiguration({ + error: executionErrorFactory({ + name: 'SubworkflowOperationError', + message: 'Subworkflow failed', + description: 'Workflow XYZ failed', + }), + lastNodeExecuted: 'NodeA', + }); + + expect(result).toEqual({ + title: 'Subworkflow failed', + message: 'Workflow XYZ failed', + }); + }); + + it('returns config for configuration-node error with node name', () => { + const result = getExecutionErrorToastConfiguration({ + error: executionErrorFactory({ + name: 'NodeOperationError', + message: 'Node failed', + description: 'Bad configuration', + functionality: 'configuration-node', + node: { name: 'TestNode' }, + }), + }); + expect(result.title).toBe('Error in sub-node ‘TestNode‘'); + expect((result.message as VNode).props).toEqual({ + errorMessage: 'Bad configuration', + nodeName: 'TestNode', + }); + }); + + it('returns config for configuration-node error without node name', () => { + const result = getExecutionErrorToastConfiguration({ + error: executionErrorFactory({ + name: 'NodeApiError', + message: 'API failed', + description: 'Missing credentials', + functionality: 'configuration-node', + node: {}, + }), + }); + + expect(result.title).toBe('Problem executing workflow'); + expect((result.message as VNode).props).toEqual({ + errorMessage: 'Missing credentials', + nodeName: '', + }); + }); + + it('returns generic config when error type is not special', () => { + const result = getExecutionErrorToastConfiguration({ + error: executionErrorFactory({ + name: 'UnknownError', + message: 'Something broke', + }), + lastNodeExecuted: 'NodeX', + }); + + expect(result).toEqual({ + title: 'Problem in node ‘NodeX‘', + message: 'Something broke', + }); + }); + + it('returns generic config without lastNodeExecuted', () => { + const result = getExecutionErrorToastConfiguration({ + error: executionErrorFactory({ + name: 'UnknownError', + message: 'Something broke', + }), + }); + expect(result).toEqual({ + title: 'Problem executing workflow', + message: 'Execution error.Details: Something broke', + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/utils/executionUtils.ts b/packages/frontend/editor-ui/src/utils/executionUtils.ts index fae38c4feac..32181e40c1a 100644 --- a/packages/frontend/editor-ui/src/utils/executionUtils.ts +++ b/packages/frontend/editor-ui/src/utils/executionUtils.ts @@ -6,6 +6,7 @@ import type { INode, IPinData, IRunData, + ExecutionError, } from 'n8n-workflow'; import type { ExecutionFilterType, ExecutionsQueryFilter, INodeUi } from '@/Interface'; import { isEmpty } from '@/utils/typesUtils'; @@ -13,6 +14,8 @@ import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, GITHUB_NODE_TYPE } from '../con import { useWorkflowsStore } from '@/stores/workflows.store'; import { useRootStore } from '@/stores/root.store'; import { i18n } from '@/plugins/i18n'; +import { h } from 'vue'; +import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue'; export function getDefaultExecutionFilters(): ExecutionFilterType { return { @@ -266,3 +269,76 @@ export function executionRetryMessage(executionStatus: ExecutionStatus): return undefined; } + +/** + * Returns the error message from the execution object if it exists, + * or a fallback error message otherwise + */ +export function getExecutionErrorMessage({ + error, + lastNodeExecuted, +}: { error?: ExecutionError; lastNodeExecuted?: string }): string { + let errorMessage: string; + + if (lastNodeExecuted && error) { + errorMessage = error.message ?? error.description ?? ''; + } else { + errorMessage = i18n.baseText('pushConnection.executionError', { + interpolate: { error: '!' }, + }); + + if (error?.message) { + let nodeName: string | undefined; + if ('node' in error) { + nodeName = typeof error.node === 'string' ? error.node : error.node?.name; + } + + const receivedError = nodeName ? `${nodeName}: ${error.message}` : error.message; + errorMessage = i18n.baseText('pushConnection.executionError', { + interpolate: { + error: `.${i18n.baseText('pushConnection.executionError.details', { + interpolate: { + details: receivedError, + }, + })}`, + }, + }); + } + } + + return errorMessage; +} + +export function getExecutionErrorToastConfiguration({ + error, + lastNodeExecuted, +}: { error: ExecutionError; lastNodeExecuted?: string }) { + const message = getExecutionErrorMessage({ error, lastNodeExecuted }); + + if (error.name === 'SubworkflowOperationError') { + return { title: error.message, message: error.description ?? '' }; + } + + if ( + (error.name === 'NodeOperationError' || error.name === 'NodeApiError') && + error.functionality === 'configuration-node' + ) { + // If the error is a configuration error of the node itself doesn't get executed so we can't use lastNodeExecuted for the title + const nodeErrorName = 'node' in error && error.node?.name ? error.node.name : ''; + + return { + title: nodeErrorName ? `Error in sub-node ‘${nodeErrorName}‘` : 'Problem executing workflow', + message: h(NodeExecutionErrorMessage, { + errorMessage: error.description ?? message, + nodeName: nodeErrorName, + }), + }; + } + + return { + title: lastNodeExecuted + ? `Problem in node ‘${lastNodeExecuted}‘` + : 'Problem executing workflow', + message, + }; +}