import { render, screen } from '@testing-library/react'; import { type Unsubscribable } from 'rxjs'; import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data'; import { config } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { ShowModalReactEvent } from 'app/types/events'; import { log } from './logs/log'; import { deepFreeze, handleErrorsInFn, getReadOnlyProxy, createOpenModalFunction, wrapWithPluginContext, getExtensionPointPluginDependencies, getExposedComponentPluginDependencies, getAppPluginConfigs, getAppPluginIdFromExposedComponentId, getAppPluginDependencies, } from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ ...jest.requireActual('app/features/plugins/pluginSettings'), getPluginSettings: () => Promise.resolve({ info: { version: '1.0.0' } }), })); describe('Plugin Extensions / Utils', () => { 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'; }).toThrowError(TypeError); }); it('should not be possible to modify values in proxied array', () => { const proxy = getReadOnlyProxy([1, 2, 3]); expect(() => { proxy[0] = 2; }).toThrowError(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'; }).toThrowError(TypeError); }); 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'; }).toThrowError(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('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); 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); 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); 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); 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); 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'); }); }); describe('wrapExtensionComponentWithContext()', () => { type ExampleComponentProps = { audience?: string; }; const ExampleComponent = (props: ExampleComponentProps) => { const pluginContext = usePluginContext(); const audience = props.audience || 'Grafana'; return (

Hello {audience}!

Version: {pluginContext!.meta.info.version}
); }; it('should make the plugin context available for the wrapped component', async () => { const pluginId = 'grafana-worldmap-panel'; const Component = wrapWithPluginContext(pluginId, 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, ExampleComponent, log); render(); expect(await screen.findByText('Hello folks!')).toBeVisible(); expect(screen.getByText('Version: 1.0.0')).toBeVisible(); }); }); 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([]); }); }); });