import { render, screen, waitFor } from '@testing-library/react'; import { type Unsubscribable } from 'rxjs'; import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data'; import { config, AppPluginConfig } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { ShowModalReactEvent } from 'app/types/events'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { deepFreeze, handleErrorsInFn, getReadOnlyProxy, createOpenModalFunction, wrapWithPluginContext, getExtensionPointPluginDependencies, getExposedComponentPluginDependencies, getAppPluginConfigs, getAppPluginIdFromExposedComponentId, getAppPluginDependencies, getExtensionPointPluginMeta, getMutationObserverProxy, writableProxy, isMutationObserverProxy, } from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ ...jest.requireActual('app/features/plugins/pluginSettings'), getPluginSettings: () => Promise.resolve({ info: { version: '1.0.0' }, id: 'test-plugin' }), })); describe('Plugin Extensions / Utils', () => { const originalEnv = config.buildInfo.env; beforeEach(() => { jest.spyOn(log, 'error').mockImplementation(() => {}); jest.spyOn(log, 'warning').mockImplementation(() => {}); jest.spyOn(log, 'debug').mockImplementation(() => {}); jest.spyOn(log, 'info').mockImplementation(() => {}); jest.spyOn(log, 'trace').mockImplementation(() => {}); jest.spyOn(log, 'fatal').mockImplementation(() => {}); }); afterEach(() => { jest.resetAllMocks(); config.buildInfo.env = originalEnv; }); describe('deepFreeze()', () => { test('should not fail when called with primitive values', () => { // Although the type system doesn't allow to call it with primitive values, it can happen that the plugin just ignores these errors. // In these cases, we would like to make sure that the function doesn't fail. // @ts-ignore expect(deepFreeze(1)).toBe(1); // @ts-ignore expect(deepFreeze('foo')).toBe('foo'); // @ts-ignore expect(deepFreeze(true)).toBe(true); // @ts-ignore expect(deepFreeze(false)).toBe(false); // @ts-ignore expect(deepFreeze(undefined)).toBe(undefined); // @ts-ignore expect(deepFreeze(null)).toBe(null); }); test('should freeze an object so it cannot be overriden', () => { const obj = { a: 1, b: '2', c: true, }; const frozen = deepFreeze(obj); expect(Object.isFrozen(frozen)).toBe(true); expect(() => { frozen.a = 234; }).toThrow(TypeError); }); test('should freeze the primitive properties of an object', () => { const obj = { a: 1, b: '2', c: true, }; const frozen = deepFreeze(obj); expect(Object.isFrozen(frozen)).toBe(true); expect(() => { frozen.a = 2; frozen.b = '3'; frozen.c = false; }).toThrow(TypeError); }); test('should return the same object (but frozen)', () => { const obj = { a: 1, b: '2', c: true, d: { e: { f: 'foo', }, }, }; const frozen = deepFreeze(obj); expect(Object.isFrozen(frozen)).toBe(true); expect(frozen).toEqual(obj); }); test('should freeze the nested object properties', () => { const obj = { a: 1, b: { c: { d: 2, e: { f: 3, }, }, }, }; const frozen = deepFreeze(obj); // Check if the object is frozen expect(Object.isFrozen(frozen)).toBe(true); // Trying to override a primitive property -> should fail expect(() => { frozen.a = 2; }).toThrow(TypeError); // Trying to override an underlying object -> should fail expect(Object.isFrozen(frozen.b)).toBe(true); expect(() => { // @ts-ignore frozen.b = {}; }).toThrow(TypeError); // Trying to override deeply nested properties -> should fail expect(() => { frozen.b.c.e.f = 12345; }).toThrow(TypeError); }); test('should not mutate the original object', () => { const obj = { a: 1, b: { c: { d: 2, e: { f: 3, }, }, }, }; deepFreeze(obj); // We should still be able to override the original object's properties expect(Object.isFrozen(obj)).toBe(false); expect(() => { obj.b.c.d = 12345; expect(obj.b.c.d).toBe(12345); }).not.toThrow(); }); test('should work with nested arrays as well', () => { const obj = { a: 1, b: { c: { d: [{ e: { f: 1 } }], }, }, }; const frozen = deepFreeze(obj); // Should be still possible to override the original object expect(() => { obj.b.c.d[0].e.f = 12345; expect(obj.b.c.d[0].e.f).toBe(12345); }).not.toThrow(); // Trying to override the frozen object throws a TypeError expect(() => { frozen.b.c.d[0].e.f = 6789; }).toThrow(); // The original object should not be mutated expect(obj.b.c.d[0].e.f).toBe(12345); expect(frozen.b.c.d).toHaveLength(1); expect(frozen.b.c.d[0].e.f).toBe(1); }); test('should not blow up when called with an object that contains cycles', () => { const obj = { a: 1, b: { c: 123, }, }; // @ts-ignore obj.b.d = obj; let frozen: typeof obj; // Check if it does not throw due to the cycle in the object expect(() => { frozen = deepFreeze(obj); }).not.toThrow(); // Check if it did freeze the object // @ts-ignore expect(Object.isFrozen(frozen)).toBe(true); // @ts-ignore expect(Object.isFrozen(frozen.b)).toBe(true); // @ts-ignore expect(Object.isFrozen(frozen.b.d)).toBe(true); }); }); describe('handleErrorsInFn()', () => { test('should catch errors thrown by the provided function and print them as console warnings', () => { global.console.warn = jest.fn(); expect(() => { const fn = handleErrorsInFn((foo: string) => { throw new Error('Error: ' + foo); }); fn('TEST'); // Logs the errors expect(console.warn).toHaveBeenCalledWith('Error: TEST'); }).not.toThrow(); }); }); describe('getReadOnlyProxy()', () => { it('should not be possible to modify values in proxied object', () => { const proxy = getReadOnlyProxy({ a: 'a' }); expect(() => { proxy.a = 'b'; }).toThrow(TypeError); }); it('should not be possible to modify values in proxied array', () => { const proxy = getReadOnlyProxy([1, 2, 3]); expect(() => { proxy[0] = 2; }).toThrow(TypeError); }); it('should not be possible to modify nested objects in proxied object', () => { const proxy = getReadOnlyProxy({ a: { c: 'c', }, b: 'b', }); expect(() => { proxy.a.c = 'testing'; }).toThrow(TypeError); }); // This is to record what we are not able to do currently. // (Due to Proxy.get() invariants limitations: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/get#invariants) it('should not work with any objects that are already frozen', () => { const obj = { a: { b: { c: { d: 'd', }, }, }, }; Object.freeze(obj); Object.freeze(obj.a); Object.freeze(obj.a.b); const proxy = getReadOnlyProxy(obj); expect(() => { proxy.a.b.c.d = 'testing'; }).toThrow( "'get' on proxy: property 'a' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected '#' but got '#')" ); expect(obj.a.b.c.d).toBe('d'); }); it('should throw a TypeError if a proxied object is trying to be frozen', () => { const obj = { a: { b: { c: { d: 'd', }, }, }, }; const proxy = getReadOnlyProxy(obj); expect(() => Object.freeze(proxy)).toThrow(TypeError); expect(() => Object.freeze(proxy.a)).toThrow(TypeError); expect(() => Object.freeze(proxy.a.b)).toThrow(TypeError); // Check if the original object is not frozen expect(Object.isFrozen(obj)).toBe(false); expect(Object.isFrozen(obj.a)).toBe(false); expect(Object.isFrozen(obj.a.b)).toBe(false); }); it('should not be possible to modify nested arrays in proxied object', () => { const proxy = getReadOnlyProxy({ a: { c: ['c', 'd'], }, b: 'b', }); expect(() => { proxy.a.c[0] = 'testing'; }).toThrow(TypeError); }); it('should be possible to modify source object', () => { const source = { a: 'b' }; getReadOnlyProxy(source); source.a = 'c'; expect(source.a).toBe('c'); }); it('should be possible to modify source array', () => { const source = ['a', 'b']; getReadOnlyProxy(source); source[0] = 'c'; expect(source[0]).toBe('c'); }); it('should be possible to modify nedsted objects in source object', () => { const source = { a: { b: 'c' } }; getReadOnlyProxy(source); source.a.b = 'd'; expect(source.a.b).toBe('d'); }); it('should be possible to modify nedsted arrays in source object', () => { const source = { a: { b: ['c', 'd'] } }; getReadOnlyProxy(source); source.a.b[0] = 'd'; expect(source.a.b[0]).toBe('d'); }); it('should be possible to call functions in proxied object', () => { const proxy = getReadOnlyProxy({ a: () => 'testing', }); expect(proxy.a()).toBe('testing'); }); it('should return a clone of moment/datetime in context', () => { const source = dateTime('2023-10-26T18:25:01Z'); const proxy = getReadOnlyProxy({ a: source, }); expect(source.isSame(proxy.a)).toBe(true); expect(source).not.toBe(proxy.a); }); }); describe('getMutationObserverProxy()', () => { describe('in development mode', () => { beforeEach(() => { config.buildInfo.env = 'development'; }); it('should be possible to modify values in proxied object, but logs an error', () => { const proxy = getMutationObserverProxy( { a: 'a' }, { pluginId: 'myorg-cool-datasource', source: 'datasource', pluginVersion: '1.2.3' } ); expect(() => { proxy.a = 'b'; }).not.toThrow(); expect(log.error).toHaveBeenCalledWith( `Attempted to mutate object property "a" from datasource with id myorg-cool-datasource and version 1.2.3`, { stack: expect.any(String), } ); expect(proxy.a).toBe('b'); }); it('should be possible to call defineProperty, but logs a debug message', () => { const obj: { a: string; b?: string } = { a: 'a' }; const proxy = getMutationObserverProxy(obj, { pluginId: 'myorg-cool-extension' }); expect(() => { Object.defineProperty(proxy, 'b', { value: 'b', writable: false, }); }).not.toThrow(); expect(log.debug).toHaveBeenCalledWith( `Attempted to define object property "b" from extension with id myorg-cool-extension and version unknown`, { stack: expect.any(String), } ); expect(proxy.b).toBe('b'); }); it('should be possible to delete properties, but logs an error', () => { const proxy = getMutationObserverProxy({ a: { c: 'c', }, b: 'b', }); expect(() => { // @ts-ignore - This is to test the logic delete proxy.a.c; }).not.toThrow(); expect(log.error).toHaveBeenCalledWith( `Attempted to delete object property "c" from extension with id unknown and version unknown`, { stack: expect.any(String), } ); expect(proxy.a.c).toBeUndefined(); }); }); describe('in production mode', () => { beforeEach(() => { config.buildInfo.env = 'production'; }); it('should be possible to modify values in proxied object, but logs a warning', () => { const proxy = getMutationObserverProxy({ a: 'a' }, { pluginId: 'myorg-cool-datasource', source: 'datasource' }); expect(() => { proxy.a = 'b'; }).not.toThrow(); expect(log.warning).toHaveBeenCalledWith( `Attempted to mutate object property "a" from datasource with id myorg-cool-datasource and version unknown`, { stack: expect.any(String), } ); expect(proxy.a).toBe('b'); }); it('should be possible to call defineProperty, but logs a debug message', () => { const obj: { a: string; b?: string } = { a: 'a' }; const proxy = getMutationObserverProxy(obj, { pluginId: 'myorg-cool-extension' }); expect(() => { Object.defineProperty(proxy, 'b', { value: 'b', writable: false, }); }).not.toThrow(); expect(log.debug).toHaveBeenCalledWith( `Attempted to define object property "b" from extension with id myorg-cool-extension and version unknown`, { stack: expect.any(String), } ); expect(proxy.b).toBe('b'); }); it('should be possible to delete properties, but logs a warning', () => { const proxy = getMutationObserverProxy({ a: { c: 'c', }, b: 'b', }); expect(() => { // @ts-ignore - This is to test the logic delete proxy.a.c; }).not.toThrow(); expect(log.warning).toHaveBeenCalledWith( `Attempted to delete object property "c" from extension with id unknown and version unknown`, { stack: expect.any(String), } ); expect(proxy.a.c).toBeUndefined(); }); }); }); describe('writableProxy()', () => { const originalEnv = config.buildInfo.env; afterEach(() => { config.buildInfo.env = originalEnv; }); it('should return the same value for primitive types', () => { expect(writableProxy(1)).toBe(1); expect(writableProxy('a')).toBe('a'); expect(writableProxy(true)).toBe(true); expect(writableProxy(false)).toBe(false); expect(writableProxy(null)).toBe(null); expect(writableProxy(undefined)).toBe(undefined); }); it('should return a writable deep-copy of the original object in dev mode', () => { config.buildInfo.env = 'development'; const obj = { a: 'a' }; const copy = writableProxy(obj, { source: 'datasource', pluginId: 'myorg-cool-datasource', pluginVersion: '1.2.3', }); expect(copy).not.toBe(obj); expect(copy.a).toBe('a'); expect(isMutationObserverProxy(copy)).toBe(true); expect(() => { copy.a = 'b'; }).not.toThrow(); expect(log.error).toHaveBeenCalledWith( `Attempted to mutate object property "a" from datasource with id myorg-cool-datasource and version 1.2.3`, { stack: expect.any(String), } ); expect(copy.a).toBe('b'); }); it('should return a writable deep-copy of the original object in production mode', () => { config.buildInfo.env = 'production'; const obj = { a: 'a' }; const copy = writableProxy(obj, { source: 'datasource', pluginId: 'myorg-cool-datasource' }); expect(copy).not.toBe(obj); expect(copy.a).toBe('a'); expect(isMutationObserverProxy(copy)).toBe(true); expect(() => { copy.a = 'b'; }).not.toThrow(); expect(log.warning).toHaveBeenCalledWith( `Attempted to mutate object property "a" from datasource with id myorg-cool-datasource and version unknown`, { stack: expect.any(String), } ); expect(copy.a).toBe('b'); }); it('should allow freezing the object in production mode', () => { config.buildInfo.env = 'production'; const obj = { a: 'a', b: { c: 'c' } }; const copy = writableProxy(obj); expect(() => { Object.freeze(copy); Object.freeze(copy.b); }).not.toThrow(); expect(Object.isFrozen(copy)).toBe(true); expect(Object.isFrozen(copy.b)).toBe(true); expect(copy.b).toEqual({ c: 'c' }); expect(log.debug).toHaveBeenCalledWith( `Attempted to define object property "a" from extension with id unknown and version unknown`, { stack: expect.any(String), } ); }); }); describe('createOpenModalFunction()', () => { let renderModalSubscription: Unsubscribable | undefined; beforeAll(() => { renderModalSubscription = appEvents.subscribe(ShowModalReactEvent, (event) => { const { payload } = event; const Modal = payload.component; render(); }); }); afterAll(() => { renderModalSubscription?.unsubscribe(); }); it('should open modal with provided title and body', async () => { const pluginId = 'grafana-worldmap-panel'; const openModal = createOpenModalFunction({ pluginId, extensionPointId: 'myorg-extensions-app/link/v1', title: 'Title in modal', }); openModal({ title: 'Title in modal', body: () =>
Text in body
, }); expect(await screen.findByRole('dialog')).toBeVisible(); expect(screen.getByRole('heading')).toHaveTextContent('Title in modal'); expect(screen.getByText('Text in body')).toBeVisible(); }); it('should open modal with default width if not specified', async () => { const pluginId = 'grafana-worldmap-panel'; const openModal = createOpenModalFunction({ pluginId, extensionPointId: 'myorg-extensions-app/link/v1', title: 'Title in modal', }); openModal({ title: 'Title in modal', body: () =>
Text in body
, }); const modal = await screen.findByRole('dialog'); const style = window.getComputedStyle(modal); expect(style.width).toBe('750px'); expect(style.height).toBe(''); }); it('should open modal with specified width', async () => { const pluginId = 'grafana-worldmap-panel'; const openModal = createOpenModalFunction({ pluginId, extensionPointId: 'myorg-extensions-app/link/v1', title: 'Title in modal', }); openModal({ title: 'Title in modal', body: () =>
Text in body
, width: '70%', }); const modal = await screen.findByRole('dialog'); const style = window.getComputedStyle(modal); expect(style.width).toBe('70%'); }); it('should open modal with specified height', async () => { const pluginId = 'grafana-worldmap-panel'; const openModal = createOpenModalFunction({ pluginId, extensionPointId: 'myorg-extensions-app/link/v1', title: 'Title in modal', }); openModal({ title: 'Title in modal', body: () =>
Text in body
, height: 600, }); const modal = await screen.findByRole('dialog'); const style = window.getComputedStyle(modal); expect(style.height).toBe('600px'); }); it('should open modal with the plugin context being available', async () => { const pluginId = 'grafana-worldmap-panel'; const openModal = createOpenModalFunction({ pluginId, extensionPointId: 'myorg-extensions-app/link/v1', title: 'Title in modal', }); const ModalContent = () => { const context = usePluginContext(); return
Version: {context!.meta.info.version}
; }; openModal({ title: 'Title in modal', body: ModalContent, }); const modal = await screen.findByRole('dialog'); expect(modal).toHaveTextContent('Version: 1.0.0'); }); it('should add a wrapper div with a "data-plugin-sandbox" attribute', async () => { const pluginId = 'grafana-worldmap-panel'; const openModal = createOpenModalFunction({ pluginId, extensionPointId: 'myorg-extensions-app/link/v1', title: 'Title in modal', }); openModal({ title: 'Title in modal', body: () =>
Text in body
, }); expect(await screen.findByRole('dialog')).toBeVisible(); expect(screen.getByTestId('plugin-sandbox-wrapper')).toHaveAttribute( 'data-plugin-sandbox', 'grafana-worldmap-panel' ); }); it('should show an error alert in the modal IN DEV MODE if the extension throws an error', async () => { config.buildInfo.env = 'development'; jest.spyOn(console, 'error').mockImplementation(() => {}); const pluginId = 'grafana-worldmap-panel'; const extensionTitle = 'Title in modal'; const openModal = createOpenModalFunction({ pluginId, extensionPointId: 'myorg-extensions-app/link/v1', title: extensionTitle, }); const ModalContent = () => { throw new Error('Test error'); }; openModal({ title: extensionTitle, body: ModalContent, }); await screen.findByRole('dialog'); expect(log.error).toHaveBeenCalledTimes(1); expect(log.error).toHaveBeenCalledWith(`Extension "${pluginId}/${extensionTitle}" failed to load.`, { message: 'Test error', componentStack: expect.any(String), digest: expect.any(String), }); expect(screen.getByText(`Extension failed to load: "${pluginId}/${extensionTitle}"`)).toBeVisible(); }); it('should also show an error alert in the modal IN PRODUCTION MODE if the extension throws an error', async () => { config.buildInfo.env = 'production'; jest.spyOn(console, 'error').mockImplementation(() => {}); const pluginId = 'grafana-worldmap-panel'; const extensionTitle = 'Title in modal'; const openModal = createOpenModalFunction({ pluginId, extensionPointId: 'myorg-extensions-app/link/v1', title: extensionTitle, }); const ModalContent = () => { throw new Error('Test error'); }; openModal({ title: extensionTitle, body: ModalContent, }); await screen.findByRole('dialog'); expect(log.error).toHaveBeenCalledTimes(1); expect(log.error).toHaveBeenCalledWith(`Extension "${pluginId}/${extensionTitle}" failed to load.`, { message: 'Test error', componentStack: expect.any(String), digest: expect.any(String), }); expect(screen.getByText(`Extension failed to load: "${pluginId}/${extensionTitle}"`)).toBeVisible(); }); }); describe('wrapWithPluginContext()', () => { type ExampleComponentProps = { a: { b: { c: string; }; }; override?: boolean; }; const ExampleComponent = (props: ExampleComponentProps) => { const pluginContext = usePluginContext(); const audience = props.a.b.c || 'Grafana'; if (props.override) { props.a.b.c = 'OVERRIDE'; } return (

Hello {audience}!

Version: {pluginContext!.meta.info.version}
); }; beforeEach(() => { resetLogMock(log); }); it('should make the plugin context available for the wrapped component', async () => { const pluginId = 'grafana-worldmap-panel'; const Component = wrapWithPluginContext({ pluginId, extensionTitle: 'ExampleComponent', Component: ExampleComponent, log, }); render(); expect(await screen.findByText('Hello Grafana!')).toBeVisible(); expect(screen.getByText('Version: 1.0.0')).toBeVisible(); }); it('should pass the properties into the wrapped component', async () => { const pluginId = 'grafana-worldmap-panel'; const Component = wrapWithPluginContext({ pluginId, extensionTitle: 'ExampleComponent', Component: ExampleComponent, log, }); render(); expect(await screen.findByText('Hello Grafana!')).toBeVisible(); expect(screen.getByText('Version: 1.0.0')).toBeVisible(); }); it('should not be possible to mutate the props in development mode, but it logs an error', async () => { config.buildInfo.env = 'development'; const pluginId = 'grafana-worldmap-panel'; const Component = wrapWithPluginContext({ pluginId, extensionTitle: 'ExampleComponent', Component: ExampleComponent, log, }); const props = { a: { b: { c: 'Grafana' } } }; render(); expect(await screen.findByText('Hello Grafana!')).toBeVisible(); // Logs a warning expect(log.error).toHaveBeenCalledTimes(1); expect(log.error).toHaveBeenCalledWith( `Attempted to mutate object property "c" from extension with id grafana-worldmap-panel and version unknown`, { stack: expect.any(String), } ); // Not able to mutate the props in dev mode either expect(props.a.b.c).toBe('Grafana'); }); it('should not be possible to mutate the props in production mode either, but it logs a warning', async () => { config.buildInfo.env = 'production'; const pluginId = 'grafana-worldmap-panel'; const Component = wrapWithPluginContext({ pluginId, extensionTitle: 'ExampleComponent', Component: ExampleComponent, log, }); const props = { a: { b: { c: 'Grafana' } } }; render(); expect(await screen.findByText('Hello Grafana!')).toBeVisible(); // Logs a warning expect(log.warning).toHaveBeenCalledTimes(1); expect(log.warning).toHaveBeenCalledWith( `Attempted to mutate object property "c" from extension with id grafana-worldmap-panel and version unknown`, { stack: expect.any(String), } ); // Not able to mutate the props in production mode either expect(props.a.b.c).toBe('Grafana'); }); it('should render an error alert IN DEV MODE if the extension throws an error', async () => { config.buildInfo.env = 'development'; jest.spyOn(console, 'error').mockImplementation(() => {}); const pluginId = 'grafana-worldmap-panel'; const ComponentWithError = () => { throw new Error('Test error'); }; const extensionTitle = 'ComponentWithError'; const WrappedComponent = wrapWithPluginContext({ pluginId, extensionTitle, Component: ComponentWithError, log, }); render(); expect(await screen.findByText(`Extension failed to load: "${pluginId}/${extensionTitle}"`)).toBeVisible(); expect(log.error).toHaveBeenCalledTimes(1); expect(log.error).toHaveBeenCalledWith(`Extension "${pluginId}/${extensionTitle}" failed to load.`, { message: 'Test error', componentStack: expect.any(String), digest: expect.any(String), }); }); it('should not render anything IN PRODUCTION MODE if the extension throws an error, but still logs an error', async () => { config.buildInfo.env = 'production'; jest.spyOn(console, 'error').mockImplementation(() => {}); const pluginId = 'grafana-worldmap-panel'; const ComponentWithError = () => { throw new Error('Test error'); }; const extensionTitle = 'ComponentWithError'; const WrappedComponent = wrapWithPluginContext({ pluginId, extensionTitle, Component: ComponentWithError, log, }); render(); await waitFor(() => expect(screen.queryByText(`Extension failed to load: "${pluginId}/${extensionTitle}"`)).not.toBeInTheDocument() ); expect(log.error).toHaveBeenCalledTimes(1); expect(log.error).toHaveBeenCalledWith(`Extension "${pluginId}/${extensionTitle}" failed to load.`, { message: 'Test error', componentStack: expect.any(String), digest: expect.any(String), }); }); }); describe('getAppPluginConfigs()', () => { const originalApps = config.apps; const genereicAppPluginConfig = { path: '', version: '', preload: false, angular: { detected: false, hideDeprecation: false, }, loadingStrategy: PluginLoadingStrategy.fetch, dependencies: { grafanaVersion: '8.0.0', plugins: [], extensions: { exposedComponents: [], }, }, extensions: { addedLinks: [], addedComponents: [], addedFunctions: [], exposedComponents: [], extensionPoints: [], }, }; afterEach(() => { config.apps = originalApps; }); test('should return the app plugin configs based on the provided plugin ids', () => { config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', }, 'myorg-third-app': { ...genereicAppPluginConfig, id: 'myorg-third-app', }, }; expect(getAppPluginConfigs(['myorg-first-app', 'myorg-third-app'])).toEqual([ config.apps['myorg-first-app'], config.apps['myorg-third-app'], ]); }); test('should simply ignore the app plugin ids that do not belong to a config', () => { config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', }, 'myorg-third-app': { ...genereicAppPluginConfig, id: 'myorg-third-app', }, }; expect(getAppPluginConfigs(['myorg-first-app', 'unknown-app-id'])).toEqual([config.apps['myorg-first-app']]); }); }); describe('getAppPluginIdFromExposedComponentId()', () => { test('should return the app plugin id from an extension point id', () => { expect(getAppPluginIdFromExposedComponentId('myorg-extensions-app/component/v1')).toBe('myorg-extensions-app'); }); }); describe('getExtensionPointPluginDependencies()', () => { const originalApps = config.apps; const genereicAppPluginConfig = { path: '', version: '', preload: false, angular: { detected: false, hideDeprecation: false, }, loadingStrategy: PluginLoadingStrategy.fetch, dependencies: { grafanaVersion: '8.0.0', plugins: [], extensions: { exposedComponents: [], }, }, extensions: { addedLinks: [], addedComponents: [], addedFunctions: [], exposedComponents: [], extensionPoints: [], }, }; afterEach(() => { config.apps = originalApps; }); test('should return the app plugin ids that register extensions to a link extension point', () => { const extensionPointId = 'myorg-first-app/link/v1'; config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, // This plugin is registering a link extension to the extension point 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', extensions: { addedLinks: [ { targets: [extensionPointId], title: 'Link title', }, ], addedComponents: [], exposedComponents: [], extensionPoints: [], addedFunctions: [], }, }, 'myorg-third-app': { ...genereicAppPluginConfig, id: 'myorg-third-app', }, }; const appPluginIds = getExtensionPointPluginDependencies(extensionPointId); expect(appPluginIds).toEqual(['myorg-second-app']); }); test('should return the app plugin ids that register extensions to a component extension point', () => { const extensionPointId = 'myorg-first-app/component/v1'; config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', }, // This plugin is registering a component extension to the extension point 'myorg-third-app': { ...genereicAppPluginConfig, id: 'myorg-third-app', extensions: { addedLinks: [], addedComponents: [ { targets: [extensionPointId], title: 'Component title', }, ], exposedComponents: [], extensionPoints: [], addedFunctions: [], }, }, }; const appPluginIds = getExtensionPointPluginDependencies(extensionPointId); expect(appPluginIds).toEqual(['myorg-third-app']); }); test('should return an empty array if there are no apps that that extend the extension point', () => { const extensionPointId = 'myorg-first-app/component/v1'; // None of the apps are extending the extension point config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', }, 'myorg-third-app': { ...genereicAppPluginConfig, id: 'myorg-third-app', }, }; const appPluginIds = getExtensionPointPluginDependencies(extensionPointId); expect(appPluginIds).toEqual([]); }); test('should also return (recursively) the app plugin ids that the apps which extend the extension-point depend on', () => { const extensionPointId = 'myorg-first-app/component/v1'; config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, // This plugin is registering a component extension to the extension point. // It is also depending on the 'myorg-fourth-app' plugin. 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', extensions: { addedLinks: [], addedComponents: [ { targets: [extensionPointId], title: 'Component title', }, ], exposedComponents: [], extensionPoints: [], addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, extensions: { exposedComponents: ['myorg-fourth-app/component/v1'], }, }, }, 'myorg-third-app': { ...genereicAppPluginConfig, id: 'myorg-third-app', }, // This plugin exposes a component, but is also depending on the 'myorg-fifth-app'. 'myorg-fourth-app': { ...genereicAppPluginConfig, id: 'myorg-fourth-app', extensions: { addedLinks: [], addedComponents: [], exposedComponents: [ { id: 'myorg-fourth-app/component/v1', title: 'Exposed component', }, ], extensionPoints: [], addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, extensions: { exposedComponents: ['myorg-fifth-app/component/v1'], }, }, }, 'myorg-fifth-app': { ...genereicAppPluginConfig, id: 'myorg-fifth-app', extensions: { addedLinks: [], addedComponents: [], exposedComponents: [ { id: 'myorg-fifth-app/component/v1', title: 'Exposed component', }, ], extensionPoints: [], addedFunctions: [], }, }, 'myorg-sixth-app': { ...genereicAppPluginConfig, id: 'myorg-sixth-app', }, }; const appPluginIds = getExtensionPointPluginDependencies(extensionPointId); expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']); }); }); describe('getExposedComponentPluginDependencies()', () => { const originalApps = config.apps; const genereicAppPluginConfig = { path: '', version: '', preload: false, angular: { detected: false, hideDeprecation: false, }, loadingStrategy: PluginLoadingStrategy.fetch, dependencies: { grafanaVersion: '8.0.0', plugins: [], extensions: { exposedComponents: [], }, }, extensions: { addedLinks: [], addedComponents: [], exposedComponents: [], extensionPoints: [], addedFunctions: [], }, }; afterEach(() => { config.apps = originalApps; }); test('should only return the app plugin id that exposes the component, if that component does not depend on anything', () => { const exposedComponentId = 'myorg-second-app/component/v1'; config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', extensions: { addedLinks: [], addedComponents: [], exposedComponents: [ { id: exposedComponentId, title: 'Component title', }, ], extensionPoints: [], addedFunctions: [], }, }, 'myorg-third-app': { ...genereicAppPluginConfig, id: 'myorg-third-app', }, }; const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId); expect(appPluginIds).toEqual(['myorg-second-app']); }); test('should also return the list of app plugin ids that the plugin - which exposes the component - is depending on', () => { const exposedComponentId = 'myorg-second-app/component/v1'; config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', extensions: { addedLinks: [], addedComponents: [], exposedComponents: [ { id: exposedComponentId, title: 'Component title', }, ], extensionPoints: [], addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, extensions: { exposedComponents: ['myorg-fourth-app/component/v1'], }, }, }, 'myorg-third-app': { ...genereicAppPluginConfig, id: 'myorg-third-app', }, 'myorg-fourth-app': { ...genereicAppPluginConfig, id: 'myorg-fourth-app', extensions: { addedLinks: [], addedComponents: [], exposedComponents: [ { id: 'myorg-fourth-app/component/v1', title: 'Component title', }, ], extensionPoints: [], addedFunctions: [], }, dependencies: { ...genereicAppPluginConfig.dependencies, extensions: { exposedComponents: ['myorg-fifth-app/component/v1'], }, }, }, 'myorg-fifth-app': { ...genereicAppPluginConfig, id: 'myorg-fifth-app', extensions: { addedLinks: [], addedComponents: [], exposedComponents: [ { id: 'myorg-fifth-app/component/v1', title: 'Component title', }, ], extensionPoints: [], addedFunctions: [], }, }, }; const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId); expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']); }); }); describe('getAppPluginDependencies()', () => { const originalApps = config.apps; const genereicAppPluginConfig = { path: '', version: '', preload: false, angular: { detected: false, hideDeprecation: false, }, loadingStrategy: PluginLoadingStrategy.fetch, dependencies: { grafanaVersion: '8.0.0', plugins: [], extensions: { exposedComponents: [], }, }, extensions: { addedLinks: [], addedComponents: [], addedFunctions: [], exposedComponents: [], extensionPoints: [], }, }; afterEach(() => { config.apps = originalApps; }); test('should not end up in an infinite loop if there are circular dependencies', () => { config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', dependencies: { ...genereicAppPluginConfig.dependencies, extensions: { exposedComponents: ['myorg-third-app/link/v1'], }, }, }, 'myorg-third-app': { ...genereicAppPluginConfig, id: 'myorg-third-app', dependencies: { ...genereicAppPluginConfig.dependencies, extensions: { exposedComponents: ['myorg-second-app/link/v1'], }, }, }, }; const appPluginIds = getAppPluginDependencies('myorg-second-app'); expect(appPluginIds).toEqual(['myorg-third-app']); }); test('should not end up in an infinite loop if a plugin depends on itself', () => { config.apps = { 'myorg-first-app': { ...genereicAppPluginConfig, id: 'myorg-first-app', }, 'myorg-second-app': { ...genereicAppPluginConfig, id: 'myorg-second-app', dependencies: { ...genereicAppPluginConfig.dependencies, extensions: { // Not a valid scenario! // (As this is sometimes happening out in the wild, we thought it's better to also cover it with a test-case.) exposedComponents: ['myorg-second-app/link/v1'], }, }, }, }; const appPluginIds = getAppPluginDependencies('myorg-second-app'); expect(appPluginIds).toEqual([]); }); }); describe('getExtensionPointPluginMeta()', () => { const originalApps = config.apps; const mockExtensionPointId = 'test-extension-point'; const mockApp1: AppPluginConfig = { id: 'app1', path: 'app1', version: '1.0.0', preload: false, angular: { detected: false, hideDeprecation: false }, loadingStrategy: PluginLoadingStrategy.fetch, dependencies: { grafanaVersion: '8.0.0', plugins: [], extensions: { exposedComponents: [], }, }, extensions: { addedComponents: [ { title: 'Component 1', targets: [mockExtensionPointId] }, { title: 'Component 2', targets: ['other-point'] }, ], addedLinks: [ { title: 'Link 1', targets: [mockExtensionPointId] }, { title: 'Link 2', targets: ['other-point'] }, ], addedFunctions: [], exposedComponents: [], extensionPoints: [], }, }; const mockApp2: AppPluginConfig = { id: 'app2', path: 'app2', version: '1.0.0', preload: false, angular: { detected: false, hideDeprecation: false }, loadingStrategy: PluginLoadingStrategy.fetch, dependencies: { grafanaVersion: '8.0.0', plugins: [], extensions: { exposedComponents: [], }, }, extensions: { addedComponents: [{ title: 'Component 3', targets: [mockExtensionPointId] }], addedLinks: [], addedFunctions: [], exposedComponents: [], extensionPoints: [], }, }; beforeEach(() => { config.apps = {}; }); afterEach(() => { config.apps = originalApps; }); it('should return empty map when no plugins have extensions for the point', () => { config.apps = { app1: { ...mockApp1, extensions: { ...mockApp1.extensions, addedComponents: [], addedLinks: [] } }, app2: { ...mockApp2, extensions: { ...mockApp2.extensions, addedComponents: [], addedLinks: [] } }, }; const result = getExtensionPointPluginMeta(mockExtensionPointId); expect(result.size).toBe(0); }); it('should return map with plugins that have components for the extension point', () => { config.apps = { app1: mockApp1, app2: mockApp2, }; const result = getExtensionPointPluginMeta(mockExtensionPointId); expect(result.size).toBe(2); expect(result.get('app1')).toEqual({ addedComponents: [{ title: 'Component 1', targets: [mockExtensionPointId] }], addedLinks: [{ title: 'Link 1', targets: [mockExtensionPointId] }], }); expect(result.get('app2')).toEqual({ addedComponents: [{ title: 'Component 3', targets: [mockExtensionPointId] }], addedLinks: [], }); }); it('should filter out plugins that do not have any extensions for the point', () => { config.apps = { app1: mockApp1, app2: { ...mockApp2, extensions: { ...mockApp2.extensions, addedComponents: [], addedLinks: [] } }, app3: { ...mockApp1, id: 'app3', extensions: { ...mockApp1.extensions, addedComponents: [{ title: 'Component 4', targets: ['other-point'] }], addedLinks: [{ title: 'Link 3', targets: ['other-point'] }], }, }, }; const result = getExtensionPointPluginMeta(mockExtensionPointId); expect(result.size).toBe(1); expect(result.get('app1')).toEqual({ addedComponents: [{ title: 'Component 1', targets: [mockExtensionPointId] }], addedLinks: [{ title: 'Link 1', targets: [mockExtensionPointId] }], }); }); }); });