diff --git a/docs/plugins/errors.md b/docs/plugins/errors.md index ddbebcf587a4a937fce41741757f708d27b9e385..25f1210aeafb1852777856d94544782ab1bb3246 100644 --- a/docs/plugins/errors.md +++ b/docs/plugins/errors.md @@ -39,3 +39,11 @@ - **Provided zoom value exeeds max zoom of ...**: This error occurs when `zoom` param of `setZoom` exeeds max zoom value of the selected map - **Provided zoom value exceeds min zoom of ...**: This error occurs when `zoom` param of `setZoom` exceeds min zoom value of the selected map + +## Event Errors + +- **Invalid event type: ...**: This error occurs when an event type is not allowed or recognized. + +## Plugin Errors + +- **Plugin "..." has crashed. Please contact the plugin developer for assistance**: This error occurs when a plugin encounters an unexpected error and crashes. Users are advised to contact the plugin developer for assistance. diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts index a14476cfb9650068b07a5185a0337441675bf09f..6ebfcea929a1c0472f8cf3493ab39056b475b823 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts @@ -46,16 +46,18 @@ export const useLoadPlugin = ({ const handleLoadPlugin = async (): Promise<void> => { try { const response = await axios(pluginUrl); - const pluginScript = response.data; - - /* eslint-disable no-new-func */ - const loadPlugin = new Function(pluginScript); + let pluginScript = response.data; PluginsManager.setHashedPlugin({ pluginUrl, pluginScript, }); + pluginScript += `//# sourceURL=${pluginUrl}`; + + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + loadPlugin(); if (onPluginLoaded) { diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts index f95bdf7e4beaa78a655090c648652a8ff20fa388..1b7c935f9365a150d9c0abfff44f20643d4c0d0f 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts @@ -35,16 +35,18 @@ export const useLoadPluginFromUrl = (): UseLoadPluginReturnType => { try { setIsLoading(true); const response = await axios(pluginUrl); - const pluginScript = response.data; - - /* eslint-disable no-new-func */ - const loadPlugin = new Function(pluginScript); + let pluginScript = response.data; const hash = PluginsManager.setHashedPlugin({ pluginUrl, pluginScript, }); + pluginScript += `//# sourceURL=${pluginUrl}`; + + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + if (!(hash in activePlugins)) { loadPlugin(); } diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts index 4daa661f7202dc3f6137bb1d579267e0b45aefd2..289742415aeeb8d5fb4f5867871a449558c8f3bd 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts @@ -61,40 +61,40 @@ describe('handleReactionResults - util', () => { expect(actions[1].type).toEqual('reactions/getByIds/fulfilled'); }); - it('should run openReactionDrawerById to empty array as second action', () => { + it('should run openReactionDrawerById to empty array as third action', () => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); expect(actions[2].type).toEqual('drawer/openReactionDrawerById'); expect(actions[2].payload).toEqual(reactionsFixture[FIRST_ARRAY_ELEMENT].id); }); - it('should run select tab as third action', () => { + it('should run select tab as fourth action', () => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); expect(actions[3].type).toEqual('drawer/selectTab'); }); - it('should run setBioEntityContent to empty array as third action', () => { + it('should run getMultiBioEntity to empty array as fifth action', () => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); expect(actions[4].type).toEqual('project/getMultiBioEntity/pending'); }); - it('should run getBioEntityContents as fourth action', () => { + it('should run getBioEntityContents as sixth action', () => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); expect(actions[5].type).toEqual('project/getBioEntityContents/pending'); }); - it('should run getBioEntityContents fullfilled as fourth action', () => { + it('should run getBioEntityContents fullfilled as seventh action', () => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[5].type).toEqual('project/getBioEntityContents/fulfilled'); + expect(actions[6].type).toEqual('project/getBioEntityContents/fulfilled'); }); - it('should run addNumbersToEntityNumberData as fifth action', () => { + it('should run addNumbersToEntityNumberData as eighth action', () => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[6].type).toEqual('entityNumber/addNumbersToEntityNumberData'); + expect(actions[7].type).toEqual('entityNumber/addNumbersToEntityNumberData'); }); }); diff --git a/src/redux/plugins/plugins.thunks.ts b/src/redux/plugins/plugins.thunks.ts index 1d69a6cbb318e9a02a78ce882e9c638199ebd324..76dc4491488086e5a89990ecd8d4fda2593cedff 100644 --- a/src/redux/plugins/plugins.thunks.ts +++ b/src/redux/plugins/plugins.thunks.ts @@ -85,11 +85,14 @@ export const getInitPlugins = createAsyncThunk<void, GetInitPluginsProps, ThunkC if (isDataValid) { const { urls } = res.data; const scriptRes = await axios(urls[0]); - const pluginScript = scriptRes.data; + let pluginScript = scriptRes.data; setHashedPlugin({ pluginUrl: urls[0], pluginScript }); + pluginScript += `//# sourceURL=${urls[0]}`; + /* eslint-disable no-new-func */ const loadPlugin = new Function(pluginScript); + loadPlugin(); } } diff --git a/src/services/pluginsManager/errorMessages.ts b/src/services/pluginsManager/errorMessages.ts index a5d4615034fb7656a01bb008ed2f3013a88f8a67..029b14e0e9f0f6256aca438d65b7e1914b219ff0 100644 --- a/src/services/pluginsManager/errorMessages.ts +++ b/src/services/pluginsManager/errorMessages.ts @@ -12,3 +12,6 @@ export const ERROR_OVERLAY_ID_NOT_ACTIVE = 'Overlay with provided id is not acti export const ERROR_OVERLAY_ID_ALREADY_ACTIVE = 'Overlay with provided id is already active'; export const ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL = 'Unable to retrieve the id of the active map: the modelId is not a number'; +export const ERROR_INVALID_EVENT_TYPE = (type: string): string => `Invalid event type: ${type}`; +export const ERROR_PLUGIN_CRASH = (pluginName: string): string => + `Plugin "${pluginName}" has crashed. Please contact the plugin developer for assistance`; diff --git a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.test.ts b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.test.ts index 474b9d214ce9d920b94c2a473cf052201294b496..c90919b45b79d6052fe4e215286838f25f4f0c85 100644 --- a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.test.ts +++ b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.test.ts @@ -1,6 +1,22 @@ /* eslint-disable no-magic-numbers */ import { createdOverlayFixture } from '@/models/fixtures/overlaysFixture'; +import { RootState, store } from '@/redux/store'; + +import { PLUGINS_MOCK } from '@/models/mocks/pluginsMock'; +import { FIRST_ARRAY_ELEMENT, SECOND_ARRAY_ELEMENT, THIRD_ARRAY_ELEMENT } from '@/constants/common'; +import { + PLUGINS_INITIAL_STATE_LIST_MOCK, + PLUGINS_INITIAL_STATE_MOCK, +} from '@/redux/plugins/plugins.mock'; +import { showToast } from '@/utils/showToast'; import { PluginsEventBus } from './pluginsEventBus'; +import { ERROR_INVALID_EVENT_TYPE, ERROR_PLUGIN_CRASH } from '../errorMessages'; + +const plugin = PLUGINS_MOCK[FIRST_ARRAY_ELEMENT]; +const secondPlugin = PLUGINS_MOCK[SECOND_ARRAY_ELEMENT]; +const thirdPlugin = PLUGINS_MOCK[THIRD_ARRAY_ELEMENT]; + +jest.mock('../../../utils/showToast'); describe('PluginsEventBus', () => { beforeEach(() => { @@ -8,12 +24,13 @@ describe('PluginsEventBus', () => { }); it('should store event listener', () => { const callback = jest.fn(); - PluginsEventBus.addListener('123', 'onAddDataOverlay', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onAddDataOverlay', callback); expect(PluginsEventBus.events).toEqual([ { - hash: '123', + hash: plugin.hash, type: 'onAddDataOverlay', + pluginName: plugin.name, callback, }, ]); @@ -21,7 +38,7 @@ describe('PluginsEventBus', () => { it('should dispatch event correctly', () => { const callback = jest.fn(); - PluginsEventBus.addListener('123', 'onAddDataOverlay', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onAddDataOverlay', callback); PluginsEventBus.dispatchEvent('onAddDataOverlay', createdOverlayFixture); expect(callback).toHaveBeenCalled(); @@ -30,29 +47,31 @@ describe('PluginsEventBus', () => { it('should throw error if event type is incorrect', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(() => PluginsEventBus.dispatchEvent('onData' as any, createdOverlayFixture)).toThrow( - 'Invalid event type: onData', + ERROR_INVALID_EVENT_TYPE('onData'), ); }); it('should remove listener only for specific plugin, event type, and callback', () => { const callback = (): void => {}; - PluginsEventBus.addListener('123', 'onAddDataOverlay', callback); - PluginsEventBus.addListener('123', 'onBioEntityClick', callback); - PluginsEventBus.addListener('234', 'onBioEntityClick', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onAddDataOverlay', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onBioEntityClick', callback); + PluginsEventBus.addListener(secondPlugin.hash, secondPlugin.name, 'onBioEntityClick', callback); expect(PluginsEventBus.events).toHaveLength(3); - PluginsEventBus.removeListener('123', 'onAddDataOverlay', callback); + PluginsEventBus.removeListener(plugin.hash, 'onAddDataOverlay', callback); expect(PluginsEventBus.events).toHaveLength(2); expect(PluginsEventBus.events).toEqual([ { callback, - hash: '123', + hash: plugin.hash, + pluginName: plugin.name, type: 'onBioEntityClick', }, { callback, - hash: '234', + hash: secondPlugin.hash, + pluginName: secondPlugin.name, type: 'onBioEntityClick', }, ]); @@ -60,13 +79,13 @@ describe('PluginsEventBus', () => { it('should throw if listener is not defined by plugin', () => { const callback = (): void => {}; - PluginsEventBus.addListener('123', 'onAddDataOverlay', callback); - PluginsEventBus.addListener('123', 'onBioEntityClick', callback); - PluginsEventBus.addListener('234', 'onBioEntityClick', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onAddDataOverlay', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onBioEntityClick', callback); + PluginsEventBus.addListener(secondPlugin.hash, secondPlugin.name, 'onBioEntityClick', callback); expect(PluginsEventBus.events).toHaveLength(3); - expect(() => PluginsEventBus.removeListener('123', 'onHideOverlay', callback)).toThrow( + expect(() => PluginsEventBus.removeListener(plugin.hash, 'onHideOverlay', callback)).toThrow( "Listener doesn't exist", ); expect(PluginsEventBus.events).toHaveLength(3); @@ -75,43 +94,117 @@ describe('PluginsEventBus', () => { const callback = (): void => {}; const secondCallback = (): void => {}; - PluginsEventBus.addListener('123', 'onAddDataOverlay', callback); - PluginsEventBus.addListener('123', 'onAddDataOverlay', secondCallback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onAddDataOverlay', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onAddDataOverlay', secondCallback); - PluginsEventBus.removeListener('123', 'onAddDataOverlay', callback); + PluginsEventBus.removeListener(plugin.hash, 'onAddDataOverlay', callback); expect(PluginsEventBus.events).toHaveLength(1); expect(PluginsEventBus.events).toEqual([ { callback: secondCallback, - hash: '123', + hash: plugin.hash, + pluginName: plugin.name, type: 'onAddDataOverlay', }, ]); }); it('should remove all listeners defined by specific plugin', () => { const callback = (): void => {}; - PluginsEventBus.addListener('123', 'onAddDataOverlay', callback); - PluginsEventBus.addListener('123', 'onBackgroundOverlayChange', callback); - PluginsEventBus.addListener('251', 'onSubmapOpen', callback); - PluginsEventBus.addListener('123', 'onHideOverlay', callback); - PluginsEventBus.addListener('123', 'onSubmapOpen', callback); - PluginsEventBus.addListener('992', 'onSearch', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onAddDataOverlay', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onBackgroundOverlayChange', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onSubmapOpen', callback); + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onHideOverlay', callback); + PluginsEventBus.addListener(secondPlugin.hash, secondPlugin.name, 'onSubmapOpen', callback); + PluginsEventBus.addListener(thirdPlugin.hash, thirdPlugin.name, 'onSearch', callback); - PluginsEventBus.removeAllListeners('123'); + PluginsEventBus.removeAllListeners(plugin.hash); expect(PluginsEventBus.events).toHaveLength(2); expect(PluginsEventBus.events).toEqual([ { callback, - hash: '251', + hash: secondPlugin.hash, type: 'onSubmapOpen', + pluginName: secondPlugin.name, }, { callback, - hash: '992', + hash: thirdPlugin.hash, type: 'onSearch', + pluginName: thirdPlugin.name, }, ]); }); + it('should show toast when event callback provided by plugin throws error', () => { + const getStateSpy = jest.spyOn(store, 'getState'); + getStateSpy.mockImplementation( + () => + ({ + plugins: { + ...PLUGINS_INITIAL_STATE_MOCK, + activePlugins: { + data: { + [plugin.hash]: plugin, + }, + pluginsId: [plugin.hash], + }, + list: PLUGINS_INITIAL_STATE_LIST_MOCK, + }, + }) as RootState, + ); + + const callbackMock = jest.fn(() => { + throw new Error('Invalid callback'); + }); + + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onPluginUnload', callbackMock); + + PluginsEventBus.dispatchEvent('onPluginUnload', { + hash: plugin.hash, + }); + + expect(callbackMock).toHaveBeenCalled(); + expect(callbackMock).toThrow('Invalid callback'); + expect(showToast).toHaveBeenCalledWith({ + message: ERROR_PLUGIN_CRASH(plugin.name), + type: 'error', + }); + }); + it('should call all event callbacks for specific event type even if one event callback provided by plugin throws error', () => { + const getStateSpy = jest.spyOn(store, 'getState'); + getStateSpy.mockImplementation( + () => + ({ + plugins: { + ...PLUGINS_INITIAL_STATE_MOCK, + activePlugins: { + data: { + [plugin.hash]: plugin, + }, + pluginsId: [plugin.hash], + }, + list: PLUGINS_INITIAL_STATE_LIST_MOCK, + }, + }) as RootState, + ); + + const errorCallbackMock = jest.fn(() => { + throw new Error('Invalid callback'); + }); + + const callbackMock = jest.fn(() => { + return 'plguin'; + }); + + PluginsEventBus.addListener(plugin.hash, plugin.name, 'onSubmapOpen', errorCallbackMock); + PluginsEventBus.addListener(secondPlugin.hash, secondPlugin.name, 'onSubmapOpen', callbackMock); + + PluginsEventBus.dispatchEvent('onSubmapOpen', 109); + + expect(errorCallbackMock).toHaveBeenCalled(); + expect(errorCallbackMock).toThrow('Invalid callback'); + + expect(callbackMock).toHaveBeenCalled(); + }); }); diff --git a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts index 7ca8a74b6529806d7c81de339b756892698c2283..9ff0bbce2e297e2012be88766bb3f07099dcd6c3 100644 --- a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts +++ b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts @@ -1,6 +1,6 @@ /* eslint-disable no-magic-numbers */ import { CreatedOverlay, MapOverlay } from '@/types/models'; -import { ALLOWED_PLUGINS_EVENTS, LISTENER_NOT_FOUND } from './pluginsEventBus.constants'; +import { showToast } from '@/utils/showToast'; import type { CenteredCoordinates, ClickedBioEntity, @@ -13,6 +13,8 @@ import type { SearchData, ZoomChanged, } from './pluginsEventBus.types'; +import { ALLOWED_PLUGINS_EVENTS, LISTENER_NOT_FOUND } from './pluginsEventBus.constants'; +import { ERROR_INVALID_EVENT_TYPE, ERROR_PLUGIN_CRASH } from '../errorMessages'; export function dispatchEvent(type: 'onPluginUnload', data: PluginUnloaded): void; export function dispatchEvent(type: 'onAddDataOverlay', createdOverlay: CreatedOverlay): void; @@ -29,12 +31,21 @@ export function dispatchEvent(type: 'onPinIconClick', data: ClickedPinIcon): voi export function dispatchEvent(type: 'onSurfaceClick', data: ClickedSurfaceOverlay): void; export function dispatchEvent(type: 'onSearch', data: SearchData): void; export function dispatchEvent(type: Events, data: EventsData): void { - if (!ALLOWED_PLUGINS_EVENTS.includes(type)) throw new Error(`Invalid event type: ${type}`); + if (!ALLOWED_PLUGINS_EVENTS.includes(type)) throw new Error(ERROR_INVALID_EVENT_TYPE(type)); // eslint-disable-next-line no-restricted-syntax, no-use-before-define for (const event of PluginsEventBus.events) { if (event.type === type) { - event.callback(data); + try { + event.callback(data); + } catch (error) { + showToast({ + message: ERROR_PLUGIN_CRASH(event.pluginName), + type: 'error', + }); + // eslint-disable-next-line no-console + console.error(error); + } } } } @@ -42,9 +53,15 @@ export function dispatchEvent(type: Events, data: EventsData): void { export const PluginsEventBus: PluginsEventBusType = { events: [], - addListener: (hash: string, type: Events, callback: (data: unknown) => void) => { + addListener: ( + hash: string, + pluginName: string, + type: Events, + callback: (data: unknown) => void, + ) => { PluginsEventBus.events.push({ hash, + pluginName, type, callback, }); diff --git a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts index 9e5a86aaf26dfb6169edb7cffcb2e31d3c7bf692..fa6d70e4801de397b941fdf088b811ae62c02fb6 100644 --- a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts +++ b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts @@ -92,10 +92,16 @@ export type EventsData = export type PluginsEventBusType = { events: { hash: string; + pluginName: string; type: Events; callback: (data: unknown) => void; }[]; - addListener: (hash: string, type: Events, callback: (data: unknown) => void) => void; + addListener: ( + hash: string, + pluginName: string, + type: Events, + callback: (data: unknown) => void, + ) => void; removeListener: (hash: string, type: Events, callback: unknown) => void; removeAllListeners: (hash: string) => void; dispatchEvent: typeof dispatchEvent; diff --git a/src/services/pluginsManager/pluginsManager.ts b/src/services/pluginsManager/pluginsManager.ts index e8e4b92407c4d85c68830f36f8e77e9491edbbff..bb42708664d32c5d9119619c242337d20839c7d6 100644 --- a/src/services/pluginsManager/pluginsManager.ts +++ b/src/services/pluginsManager/pluginsManager.ts @@ -130,7 +130,7 @@ export const PluginsManager: PluginsManagerType = { return { element, events: { - addListener: PluginsEventBus.addListener.bind(this, hash), + addListener: PluginsEventBus.addListener.bind(this, hash, pluginName), removeListener: PluginsEventBus.removeListener.bind(this, hash), removeAllListeners: PluginsEventBus.removeAllListeners.bind(this, hash), },