Skip to content
Snippets Groups Projects
Commit 655d3f01 authored by mateusz-winiarczyk's avatar mateusz-winiarczyk
Browse files

Merge branch 'MIN-298-plugins-error-in-listener-failing-silently' into 'development'

feat(plugins): error in listener is failing silently

Closes MIN-298

See merge request !160
parents fdcf2492 4934de74
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!160feat(plugins): error in listener is failing silently
Pipeline #87967 passed
Showing with 184 additions and 50 deletions
......@@ -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.
......@@ -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) {
......
......@@ -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();
}
......
......@@ -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');
});
});
......@@ -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();
}
}
......
......@@ -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`;
/* 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();
});
});
/* 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,
});
......
......@@ -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;
......
......@@ -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),
},
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment