diff --git a/.changeset/twelve-poets-type.md b/.changeset/twelve-poets-type.md new file mode 100644 index 00000000000..b9f19f131c6 --- /dev/null +++ b/.changeset/twelve-poets-type.md @@ -0,0 +1,6 @@ +--- +'@sap-ux-private/preview-middleware-client': patch +"@sap-ux/preview-middleware": patch +--- + +feat: Display unhandled exceptions from the Controller extension inside the Info Center. diff --git a/packages/preview-middleware-client/src/flp/init.ts b/packages/preview-middleware-client/src/flp/init.ts index 1bf6a711ee2..1eba2b3372e 100644 --- a/packages/preview-middleware-client/src/flp/init.ts +++ b/packages/preview-middleware-client/src/flp/init.ts @@ -43,6 +43,10 @@ const UI5_LIBS = [ 'sap.zen' ]; +const CONTROLLER_EXTENSION_PATH_REGEX = /\/changes\/coding\/.+\.(js|ts)/; + +type GlobalErrorEvent = ErrorEvent | PromiseRejectionEvent; + interface Manifest { ['sap.ui5']?: { dependencies?: { @@ -278,6 +282,55 @@ function addCardGenerationUserAction(componentInstance: Component, container: ty }); } +/** + * Extracts an Error object from a global error event. + * Handles both synchronous errors (ErrorEvent) and unhandled promise rejections (PromiseRejectionEvent). + * + * @param {GlobalErrorEvent} event - The global error or unhandled rejection event. + * @returns {Error | undefined} The extracted Error instance, or undefined if no Error could be extracted. + */ +function extractError(event: GlobalErrorEvent): Error | undefined { + if ('error' in event && event.error instanceof Error) { + return event.error; + } + + if ('reason' in event && event.reason instanceof Error) { + return event.reason; + } + + return undefined; +} + +/** + * Reports controller extension errors to the Info Center. + * Filters events by checking if the stack trace contains 'ControllerExtension', + * and sends matching errors as error-level messages to the Info Center panel. + * + * @param {GlobalErrorEvent} event - The global error or unhandled rejection event. + */ +const reportControllerExtensionErrorToInfoCenter: (event: GlobalErrorEvent) => void = (event) => { + const error = extractError(event); + const stackTrace = error?.stack ?? ''; + if (!CONTROLLER_EXTENSION_PATH_REGEX.test(stackTrace)) { + return; + } + void sendInfoCenterMessage({ + title: { key: 'CONTROLLER_EXTENSION_UNHANDLED_ERROR_TITLE' }, + description: error?.message ?? '', + type: MessageBarType.error, + details: stackTrace + }); +}; + +/** + * Registers global event listeners for uncaught errors and unhandled promise rejections + * to detect and report controller extension errors to the Info Center. + */ +function registerForControllerExtensionErrors(): void { + globalThis.addEventListener('error', reportControllerExtensionErrorToInfoCenter); + globalThis.addEventListener('unhandledrejection', reportControllerExtensionErrorToInfoCenter); +} + /** * Apply additional configuration and initialize sandbox. * @@ -309,6 +362,7 @@ export async function init({ const ui5VersionInfo = await getUi5Version(); // Register RTA if configured if (flex) { + registerForControllerExtensionErrors(); const flexSettings = JSON.parse(flex) as FlexSettings; scenario = flexSettings.scenario; container.attachRendererCreatedEvent(async function () { diff --git a/packages/preview-middleware-client/src/messagebundle.properties b/packages/preview-middleware-client/src/messagebundle.properties index 7907040f47c..d2ed4b83c40 100644 --- a/packages/preview-middleware-client/src/messagebundle.properties +++ b/packages/preview-middleware-client/src/messagebundle.properties @@ -83,6 +83,7 @@ FLP_UI_INVALID_UI5_VERSION_DESCRIPTION = Invalid version info FLP_UI_VERSION_RETRIEVAL_FAILURE_DESCRIPTION = Could not get the SAPUI5 version of the application. Using {0} as fallback. FLP_UI5_VERSION_WARNING_TITLE = SAPUI5 Version Warning FLP_UI5_VERSION_WARNING_DESCRIPTION = The current SAPUI5 version set for this adaptation project is {0}. The minimum version for SAPUI5 Adaptation Project and its SAPUI5 Adaptation Editor is {1}. Install version {1} or higher. +CONTROLLER_EXTENSION_UNHANDLED_ERROR_TITLE = Controller Extension Unhandled Error TABLE_ROWS_NEEDED_TO_CREATE_CUSTOM_COLUMN=At least one table row is required to create a new custom column. Make sure the table data is loaded and try again. diff --git a/packages/preview-middleware-client/test/unit/flp/init.test.ts b/packages/preview-middleware-client/test/unit/flp/init.test.ts index 078f7d0361b..4014c9ea0c4 100644 --- a/packages/preview-middleware-client/test/unit/flp/init.test.ts +++ b/packages/preview-middleware-client/test/unit/flp/init.test.ts @@ -8,7 +8,6 @@ import NewsAndPagesContainer from 'sap/cux/home/NewsAndPagesContainer'; import { CommunicationService } from 'open/ux/preview/client/cpe/communication-service'; import type Component from 'sap/ui/core/Component'; import type { InitRtaScript, RTAPlugin } from 'sap/ui/rta/api/startAdaptation'; -import { Window } from 'types/global'; import * as apiHandler from '../../../src/adp/api-handler'; import MyHomeController from '../../../src/flp/homepage/controller/MyHome.controller'; import { @@ -19,6 +18,7 @@ import { resetAppState, setI18nTitle } from '../../../src/flp/init'; +import * as infoCenterMessage from '../../../src/utils/info-center-message'; describe('flp/init', () => { afterEach(() => { @@ -555,4 +555,93 @@ describe('flp/init', () => { }); }); }); + + describe('registerForControllerExtensionErrors', () => { + let sendInfoCenterMessageSpy: jest.SpyInstance; + + beforeEach(async () => { + sendInfoCenterMessageSpy = jest + .spyOn(infoCenterMessage, 'sendInfoCenterMessage') + .mockResolvedValue(undefined); + + const flexSettings = { layer: 'CUSTOMER_BASE' }; + VersionInfo.load.mockResolvedValue({ + name: 'SAPUI5 Distribution', + libraries: [{ name: 'sap.ui.core', version: '1.118.1' }] + }); + CommunicationService.sendAction = jest.fn(); + await init({ flex: JSON.stringify(flexSettings) }); + }); + + afterEach(() => { + sendInfoCenterMessageSpy.mockRestore(); + }); + + test('reports error event with controller extension path in stack trace', async () => { + const error = new Error('Something went wrong'); + error.stack = 'Error: Something went wrong\n at /changes/coding/MyExtension.js:10:5'; + const errorEvent = new ErrorEvent('error', { error }); + globalThis.dispatchEvent(errorEvent); + + expect(sendInfoCenterMessageSpy).toHaveBeenCalledWith({ + title: { key: 'CONTROLLER_EXTENSION_UNHANDLED_ERROR_TITLE' }, + description: 'Something went wrong', + type: MessageBarType.error, + details: error.stack + }); + }); + + test('reports unhandled rejection with controller extension .ts path in stack trace', async () => { + const error = new Error('Async failure'); + error.stack = 'Error: Async failure\n at /changes/coding/MyExtension.ts:20:3'; + + const rejectionEvent = new Event('unhandledrejection') as any; + rejectionEvent.reason = error; + globalThis.dispatchEvent(rejectionEvent); + + expect(sendInfoCenterMessageSpy).toHaveBeenCalledWith({ + title: { key: 'CONTROLLER_EXTENSION_UNHANDLED_ERROR_TITLE' }, + description: 'Async failure', + type: MessageBarType.error, + details: error.stack + }); + }); + + test('ignores error event without controller extension path in stack trace', async () => { + const error = new Error('Unrelated error'); + error.stack = 'Error: Unrelated error\n at /some/other/path.js:5:1'; + const errorEvent = new ErrorEvent('error', { error }); + globalThis.dispatchEvent(errorEvent); + + expect(sendInfoCenterMessageSpy).not.toHaveBeenCalled(); + }); + + test('ignores error event when error is not an Error instance', async () => { + const errorEvent = new ErrorEvent('error', { error: 'string error' } as any); + globalThis.dispatchEvent(errorEvent); + + expect(sendInfoCenterMessageSpy).not.toHaveBeenCalled(); + }); + + test('ignores error event when error is undefined', async () => { + const errorEvent = new ErrorEvent('error', {}); + globalThis.dispatchEvent(errorEvent); + + expect(sendInfoCenterMessageSpy).not.toHaveBeenCalled(); + }); + + test('reports error with empty message when error.message is empty', async () => { + const error = new Error(''); + error.stack = 'Error\n at /changes/coding/Controller.js:1:1'; + const errorEvent = new ErrorEvent('error', { error }); + globalThis.dispatchEvent(errorEvent); + + expect(sendInfoCenterMessageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + description: '', + type: MessageBarType.error + }) + ); + }); + }); });