/usr/share/grafana/public/app/features/plugins/extensions
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 '#<Object>' but got '#<Object>')" ); 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(<Modal />); }); }); 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: () => <div>Text in body</div>, }); 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: () => <div>Text in body</div>, }); 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: () => <div>Text in body</div>, 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: () => <div>Text in body</div>, 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 <div>Version: {context!.meta.info.version}</div>; }; 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: () => <div>Text in body</div>, }); 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 ( <div> <h1>Hello {audience}!</h1> Version: {pluginContext!.meta.info.version} </div> ); }; 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(<Component a={{ b: { c: 'Grafana' } }} />); 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(<Component a={{ b: { c: 'Grafana' } }} />); 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(<Component {...props} override />); 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 1.0.0`, { 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(<Component {...props} override />); 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 1.0.0`, { 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(<WrappedComponent />); 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(<WrappedComponent />); 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] }], }); }); }); });
.
Edit
..
Edit
ExtensionErrorAlert.tsx
Edit
ExtensionErrorBoundary.tsx
Edit
ExtensionRegistriesContext.tsx
Edit
errors.ts
Edit
getCoreExtensionConfigurations.ts
Edit
getPluginExtensions.test.tsx
Edit
getPluginExtensions.ts
Edit
logs
Edit
registry
Edit
types.ts
Edit
useLoadAppPlugins.tsx
Edit
usePluginComponent.test.tsx
Edit
usePluginComponent.tsx
Edit
usePluginComponents.test.tsx
Edit
usePluginComponents.tsx
Edit
usePluginFunctions.test.tsx
Edit
usePluginFunctions.tsx
Edit
usePluginLinks.test.tsx
Edit
usePluginLinks.tsx
Edit
utils.test.tsx
Edit
utils.tsx
Edit
validateExtensionPoint.test.ts
Edit
validateExtensionPoint.ts
Edit
validators.test.tsx
Edit
validators.ts
Edit