Plugin Extensions: refactors shared logic into validator function (#110434)

* Plugin Extensions: consolidate logic

* chore: add specific error to tests

* chore: refactors out common parts to getExtensionValidationResults

* chore: updates after PR feedback

* chore: update after PR feedback
This commit is contained in:
Hugo Häggmark
2025-09-08 06:22:27 +02:00
committed by GitHub
parent 23fa9a1484
commit 1fd4611487
8 changed files with 792 additions and 145 deletions
@@ -11,6 +11,7 @@ import {
import { config } from '@grafana/runtime';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
@@ -28,7 +29,7 @@ jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
// Manually set the dev mode to false
// (to make sure that by default we are testing a production scneario)
// (to make sure that by default we are testing a production scenario)
isGrafanaDevMode: jest.fn().mockReturnValue(false),
}));
@@ -486,7 +487,7 @@ describe('usePluginComponents()', () => {
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to isGrafanaDevMode() = false)
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
expect(result.current.components.length).toBe(1);
expect(log.error).not.toHaveBeenCalled();
});
@@ -529,7 +530,7 @@ describe('usePluginComponents()', () => {
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to being a core plugin)
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
expect(result.current.components.length).toBe(1);
expect(log.error).not.toHaveBeenCalled();
});
@@ -552,7 +553,7 @@ describe('usePluginComponents()', () => {
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to isGrafanaDevMode() = false)
let { result } = renderHook(() => usePluginComponents({ extensionPointId: 'invalid-extension-point-id' }), {
const { result } = renderHook(() => usePluginComponents({ extensionPointId: 'invalid-extension-point-id' }), {
wrapper,
});
expect(result.current.components.length).toBe(0);
@@ -581,7 +582,7 @@ describe('usePluginComponents()', () => {
],
});
let { result } = renderHook(
const { result } = renderHook(
() => usePluginComponents({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu }),
{
wrapper,
@@ -615,7 +616,7 @@ describe('usePluginComponents()', () => {
],
});
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), {
const { result } = renderHook(() => usePluginComponents({ extensionPointId }), {
wrapper,
});
expect(result.current.components.length).toBe(0);
@@ -655,9 +656,10 @@ describe('usePluginComponents()', () => {
});
// Trying to render an extension point that is not defined in the plugin meta
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
expect(result.current.components.length).toBe(0);
expect(log.error).toHaveBeenCalled();
expect(log.error).toHaveBeenCalledWith(errors.EXTENSION_POINT_META_INFO_MISSING);
});
it('should not log an error if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
@@ -699,7 +701,7 @@ describe('usePluginComponents()', () => {
});
// Trying to render an extension point that is not defined in the plugin meta
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
expect(result.current.components.length).toBe(0);
expect(log.error).toHaveBeenCalled();
});
@@ -10,12 +10,10 @@ import {
import { UsePluginComponentsOptions, UsePluginComponentsResult } from '@grafana/runtime';
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
import { generateExtensionId, getExtensionPointPluginDependencies } from './utils';
import { validateExtensionPoint } from './validateExtensionPoint';
// Returns an array of component extensions for the given extension point
export function usePluginComponents<Props extends object = {}>({
@@ -28,47 +26,17 @@ export function usePluginComponents<Props extends object = {}>({
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => {
const isInsidePlugin = Boolean(pluginContext);
const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false;
const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins });
if (result) {
return {
isLoading: result.isLoading,
components: [],
};
}
const components: Array<ComponentTypeWithExtensionMeta<Props>> = [];
const extensionsByPlugin: Record<string, number> = {};
const pluginId = pluginContext?.meta.id ?? '';
const pointLog = log.child({
pluginId,
extensionPointId,
});
// Don't show extensions if the extension-point id is invalid in DEV mode
if (
isGrafanaDevMode() &&
!isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog })
) {
return {
isLoading: false,
components: [],
};
}
// Don't show extensions if the extension-point misses meta info (plugin.json) in DEV mode
if (
isGrafanaDevMode() &&
!isCoreGrafanaPlugin &&
pluginContext &&
isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)
) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
components: [],
};
}
if (isLoadingAppPlugins) {
return {
isLoading: true,
components: [],
};
}
for (const registryItem of registryState?.[extensionPointId] ?? []) {
const { pluginId } = registryItem;
@@ -0,0 +1,502 @@
import { act, renderHook } from '@testing-library/react';
import {
PluginContextProvider,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginMeta,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginFunctions } from './usePluginFunctions';
import { isGrafanaDevMode } from './utils';
jest.mock('./useLoadAppPlugins');
jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({
id: 'my-app-plugin',
enabled: true,
jsonData: {},
type: 'panel',
name: 'My App Plugin',
module: 'app/plugins/my-app-plugin/module',
}),
}));
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
// Manually set the dev mode to false
// (to make sure that by default we are testing a production scenario)
isGrafanaDevMode: jest.fn().mockReturnValue(false),
}));
jest.mock('./logs/log', () => {
const { createLogMock } = jest.requireActual('./logs/testUtils');
const original = jest.requireActual('./logs/log');
return {
...original,
log: createLogMock(),
};
});
describe('usePluginFunctions()', () => {
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
let pluginMeta: PluginMeta;
const pluginId = 'myorg-extensions-app';
const extensionPointId = `${pluginId}/extension-point/v1`;
beforeEach(() => {
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
resetLogMock(log);
pluginMeta = {
id: pluginId,
name: 'Extensions App',
type: PluginType.app,
module: '',
baseUrl: '',
info: {
author: {
name: 'MyOrg',
},
description: 'App for testing extensions',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '2023-10-26T18:25:01Z',
version: '1.0.0',
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
};
config.apps[pluginId] = {
id: pluginId,
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: [],
},
};
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
</PluginContextProvider>
);
});
it('should return an empty array if there are no function extensions registered for the extension point', () => {
const { result } = renderHook(
() =>
usePluginFunctions({
extensionPointId: 'foo/bar',
}),
{ wrapper }
);
expect(result.current.functions).toEqual([]);
});
it('should only return the function extensions for the given extension point ids', async () => {
registries.addedFunctionsRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
fn: () => 'function1',
},
{
targets: extensionPointId,
title: '2',
description: '2',
fn: () => 'function2',
},
{
targets: 'plugins/another-extension/v1',
title: '3',
description: '3',
fn: () => 'function3',
},
],
});
const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper });
expect(result.current.functions.length).toBe(2);
expect(result.current.functions[0].title).toBe('1');
expect(result.current.functions[1].title).toBe('2');
});
it('should dynamically update the extensions registered for a certain extension point', () => {
let { result, rerender } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper });
// No extensions yet
expect(result.current.functions.length).toBe(0);
// Add extensions to the registry
act(() => {
registries.addedFunctionsRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
fn: () => 'function1',
},
{
targets: extensionPointId,
title: '2',
description: '2',
fn: () => 'function2',
},
],
});
});
// Check if the hook returns the new extensions
rerender();
expect(result.current.functions.length).toBe(2);
expect(result.current.functions[0].title).toBe('1');
expect(result.current.functions[1].title).toBe('2');
});
it('should honour the limitPerPlugin arg if its set', () => {
const plugins = ['my-awesome1-app', 'my-awesome2-app', 'my-awesome3-app'];
let { result, rerender } = renderHook(() => usePluginFunctions({ extensionPointId, limitPerPlugin: 2 }), {
wrapper,
});
// No extensions yet
expect(result.current.functions.length).toBe(0);
// Add extensions to the registry
act(() => {
for (let pluginId of plugins) {
registries.addedFunctionsRegistry.register({
pluginId,
configs: [
{
targets: [extensionPointId],
title: '1',
description: '1',
fn: () => 'function1',
},
{
targets: [extensionPointId],
title: '2',
description: '2',
fn: () => 'function2',
},
{
targets: [extensionPointId],
title: '3',
description: '3',
fn: () => 'function3',
},
],
});
}
});
// Check if the hook returns the new extensions
rerender();
// Should only return 2 functions per plugin due to limitPerPlugin: 2
expect(result.current.functions.length).toBe(6);
});
it('should return isLoading: true when app plugins are loading', () => {
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: true });
const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper });
expect(result.current.isLoading).toBe(true);
expect(result.current.functions).toEqual([]);
});
it('should return isLoading: false when app plugins are not loading', () => {
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.functions).toEqual([]);
});
it('should not validate the extension point meta-info in production mode', () => {
// Empty list of extension points in the plugin meta (from plugin.json)
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
meta={{
...pluginMeta,
extensions: {
...pluginMeta.extensions!,
extensionPoints: [],
},
}}
>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
</PluginContextProvider>
);
registries.addedFunctionsRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
fn: () => 'function1',
},
],
});
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to isGrafanaDevMode() = false)
const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper });
expect(result.current.functions.length).toBe(1);
expect(log.error).not.toHaveBeenCalled();
});
// It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points.
it('should not validate the extension point meta-info for core plugins', () => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const functionConfig = {
targets: extensionPointId,
title: '1',
description: '1',
fn: () => 'function1',
};
// The `AddedFunctionsRegistry` is validating if the function is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedFunctions = [functionConfig];
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
meta={{
...pluginMeta,
// The module tells if it is a core plugin
module: 'core:plugin/traces',
extensions: {
...pluginMeta.extensions!,
// Empty list of extension points in the plugin meta (from plugin.json)
extensionPoints: [],
},
}}
>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
</PluginContextProvider>
);
registries.addedFunctionsRegistry.register({
pluginId,
configs: [functionConfig],
});
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to being a core plugin)
const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper });
expect(result.current.functions.length).toBe(1);
expect(log.error).not.toHaveBeenCalled();
});
it('should not validate the extension point id in production mode', () => {
// Empty list of extension points in the plugin meta (from plugin.json)
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
meta={{
...pluginMeta,
extensions: {
...pluginMeta.extensions!,
extensionPoints: [],
},
}}
>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
</PluginContextProvider>
);
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to isGrafanaDevMode() = false)
const { result } = renderHook(() => usePluginFunctions({ extensionPointId: 'invalid-extension-point-id' }), {
wrapper,
});
expect(result.current.functions.length).toBe(0);
expect(log.error).not.toHaveBeenCalled();
});
it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => {
// Imitate running in dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
// No plugin context -> used in Grafana core
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
);
// Adding an extension to the extension point
registries.addedFunctionsRegistry.register({
pluginId: 'grafana', // Only core Grafana can register extensions without a plugin context
configs: [
{
targets: PluginExtensionPoints.DashboardPanelMenu,
title: '1',
description: '1',
fn: () => 'function1',
},
],
});
const { result } = renderHook(
() => usePluginFunctions({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu }),
{
wrapper,
}
);
expect(result.current.functions.length).toBe(1);
expect(log.error).not.toHaveBeenCalled();
});
it('should not allow to create an extension point in core Grafana that is not exposed to plugins', () => {
// Imitate running in dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
// No plugin context -> used in Grafana core
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
);
const extensionPointId = 'grafana/not-exposed-extension-point/v1';
// Adding an extension to the extension point
registries.addedFunctionsRegistry.register({
pluginId: 'grafana', // Only core Grafana can register extensions without a plugin context
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
fn: () => 'function1',
},
],
});
const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper });
expect(result.current.functions.length).toBe(0);
expect(log.error).toHaveBeenCalled();
});
it('should not validate the extension point id if used in Grafana core (no plugin context)', () => {
// Imitate running in dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
// No plugin context -> used in Grafana core
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
);
const { result } = renderHook(() => usePluginFunctions({ extensionPointId: 'invalid-extension-point-id' }), {
wrapper,
});
expect(result.current.functions.length).toBe(0);
expect(log.warning).not.toHaveBeenCalled();
});
it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
// Imitate running in dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
// Empty list of extension points in the plugin meta (from plugin.json)
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
meta={{
...pluginMeta,
extensions: {
...pluginMeta.extensions!,
extensionPoints: [],
},
}}
>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
</PluginContextProvider>
);
// Adding an extension to the extension point - it should not be returned later
registries.addedFunctionsRegistry.register({
pluginId,
configs: [
{
targets: extensionPointId,
title: '1',
description: '1',
fn: () => 'function1',
},
],
});
// Trying to render an extension point that is not defined in the plugin meta
const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper });
expect(result.current.functions.length).toBe(0);
expect(log.error).toHaveBeenCalled();
expect(log.error).toHaveBeenCalledWith(errors.EXTENSION_POINT_META_INFO_MISSING);
});
});
@@ -5,11 +5,9 @@ import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from
import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime';
import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
import { generateExtensionId, getExtensionPointPluginDependencies } from './utils';
import { validateExtensionPoint } from './validateExtensionPoint';
// Returns an array of component extensions for the given extension point
export function usePluginFunctions<Signature>({
@@ -23,45 +21,17 @@ export function usePluginFunctions<Signature>({
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps);
return useMemo(() => {
const isInsidePlugin = Boolean(pluginContext);
const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false;
const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins });
if (result) {
return {
isLoading: result.isLoading,
functions: [],
};
}
const results: Array<PluginExtensionFunction<Signature>> = [];
const extensionsByPlugin: Record<string, number> = {};
const pluginId = pluginContext?.meta.id ?? '';
const pointLog = log.child({
pluginId,
extensionPointId,
});
if (
isGrafanaDevMode() &&
!isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog })
) {
return {
isLoading: false,
functions: [],
};
}
if (
isGrafanaDevMode() &&
!isCoreGrafanaPlugin &&
pluginContext &&
isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)
) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
functions: [],
};
}
if (isLoadingAppPlugins) {
return {
isLoading: true,
functions: [],
};
}
for (const registryItem of registryState?.[extensionPointId] ?? []) {
const { pluginId } = registryItem;
@@ -10,6 +10,7 @@ import {
import { config } from '@grafana/runtime';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
@@ -37,7 +38,7 @@ jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
// Manually set the dev mode to false
// (to make sure that by default we are testing a production scneario)
// (to make sure that by default we are testing a production scenario)
isGrafanaDevMode: jest.fn().mockReturnValue(false),
}));
@@ -247,7 +248,7 @@ describe('usePluginLinks()', () => {
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to isGrafanaDevMode() = false)
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
expect(result.current.links.length).toBe(1);
expect(log.warning).not.toHaveBeenCalled();
});
@@ -290,7 +291,7 @@ describe('usePluginLinks()', () => {
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to being a core plugin)
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
expect(result.current.links.length).toBe(1);
expect(log.warning).not.toHaveBeenCalled();
});
@@ -313,7 +314,9 @@ describe('usePluginLinks()', () => {
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to isGrafanaDevMode() = false)
let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper });
const { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), {
wrapper,
});
expect(result.current.links.length).toBe(0);
expect(log.warning).not.toHaveBeenCalled();
});
@@ -340,9 +343,12 @@ describe('usePluginLinks()', () => {
],
});
let { result } = renderHook(() => usePluginLinks({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu }), {
wrapper,
});
const { result } = renderHook(
() => usePluginLinks({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu }),
{
wrapper,
}
);
expect(result.current.links.length).toBe(1);
expect(log.warning).not.toHaveBeenCalled();
});
@@ -371,7 +377,7 @@ describe('usePluginLinks()', () => {
],
});
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
expect(result.current.links.length).toBe(0);
expect(log.error).toHaveBeenCalled();
});
@@ -385,7 +391,9 @@ describe('usePluginLinks()', () => {
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
);
let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper });
const { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), {
wrapper,
});
expect(result.current.links.length).toBe(0);
expect(log.warning).not.toHaveBeenCalled();
});
@@ -423,9 +431,10 @@ describe('usePluginLinks()', () => {
});
// Trying to render an extension point that is not defined in the plugin meta
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
expect(result.current.links.length).toBe(0);
expect(log.error).toHaveBeenCalled();
expect(log.error).toHaveBeenCalledWith(errors.EXTENSION_POINT_META_INFO_MISSING);
});
it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
@@ -461,7 +470,7 @@ describe('usePluginLinks()', () => {
});
// Trying to render an extension point that is not defined in the plugin meta
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
expect(result.current.links.length).toBe(0);
expect(log.error).toHaveBeenCalled();
});
@@ -6,8 +6,6 @@ import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@gr
import { UsePluginLinksOptions, UsePluginLinksResult } from '@grafana/runtime';
import { useAddedLinksRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import {
generateExtensionId,
@@ -16,9 +14,8 @@ import {
getLinkExtensionOverrides,
getLinkExtensionPathWithTracking,
getReadOnlyProxy,
isGrafanaDevMode,
} from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
import { validateExtensionPoint } from './validateExtensionPoint';
// Returns an array of component extensions for the given extension point
export function usePluginLinks({
@@ -32,47 +29,15 @@ export function usePluginLinks({
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => {
const isInsidePlugin = Boolean(pluginContext);
const pluginId = pluginContext?.meta.id ?? '';
const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false;
const pointLog = log.child({
pluginId,
const { result, pointLog } = validateExtensionPoint({
extensionPointId,
pluginContext,
isLoadingAppPlugins,
});
if (
isGrafanaDevMode() &&
!isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog })
) {
if (result) {
return {
isLoading: false,
links: [],
};
}
if (
isGrafanaDevMode() &&
!isCoreGrafanaPlugin &&
pluginContext &&
isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)
) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
links: [],
};
}
if (isLoadingAppPlugins) {
return {
isLoading: true,
links: [],
};
}
if (!registryState || !registryState[extensionPointId]) {
return {
isLoading: false,
isLoading: result.isLoading,
links: [],
};
}
@@ -81,7 +46,7 @@ export function usePluginLinks({
const extensions: PluginExtensionLink[] = [];
const extensionsByPlugin: Record<string, number> = {};
for (const addedLink of registryState[extensionPointId] ?? []) {
for (const addedLink of registryState?.[extensionPointId] ?? []) {
const { pluginId } = addedLink;
const linkLog = pointLog.child({
path: addedLink.path ?? '',
@@ -0,0 +1,174 @@
import { PluginContextType } from '@grafana/data';
import * as errors from './errors';
import { ExtensionsLog } from './logs/log';
import { isGrafanaDevMode } from './utils';
import { validateExtensionPoint } from './validateExtensionPoint';
import * as validators from './validators';
jest.mock('./utils', () => ({
...jest.requireActual('./utils'),
// Manually set the dev mode to false
// (to make sure that by default we are testing a production scenario)
isGrafanaDevMode: jest.fn().mockReturnValue(false),
}));
const setup = ({
pointValid = true,
metaMissing = false,
corePlugin = false,
}: { pointValid?: boolean; metaMissing?: boolean; corePlugin?: boolean } = {}) => {
const spyIsExtensionPointIdValid = jest.spyOn(validators, 'isExtensionPointIdValid').mockReturnValue(pointValid);
const spyisExtensionPointMetaInfoMissing = jest
.spyOn(validators, 'isExtensionPointMetaInfoMissing')
.mockReturnValue(metaMissing);
const pluginId = 'myorg-extensions-app';
const extensionPointId = `${pluginId}/extension-point/v1`;
const pluginContext = { meta: { id: pluginId, module: corePlugin ? 'core:' : '' } } as PluginContextType;
return {
spyIsExtensionPointIdValid,
spyisExtensionPointMetaInfoMissing,
pluginId,
extensionPointId,
pluginContext,
};
};
describe('getExtensionValidationResults', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when calling in production mode', () => {
beforeEach(() => {
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
});
it('should return isLoading:true while loading app plugins', () => {
const { extensionPointId, pluginContext } = setup();
const actual = validateExtensionPoint({
extensionPointId,
isLoadingAppPlugins: true,
pluginContext,
});
expect(actual.result).toEqual({ isLoading: true });
expect(actual.pointLog).toBeDefined();
});
it('should return null when all validations pass', () => {
const { extensionPointId, pluginContext } = setup();
const actual = validateExtensionPoint({
extensionPointId,
isLoadingAppPlugins: false,
pluginContext,
});
expect(actual.result).toBe(null);
expect(actual.pointLog).toBeDefined();
});
});
describe('when calling in dev mode', () => {
let errorSpy: jest.SpyInstance;
beforeEach(() => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
errorSpy = jest.spyOn(console, 'error').mockImplementation();
});
it('should return isLoading:false when extension point is invalid', () => {
const {
extensionPointId,
pluginContext,
pluginId,
spyIsExtensionPointIdValid,
spyisExtensionPointMetaInfoMissing,
} = setup({ pointValid: false });
const actual = validateExtensionPoint({
extensionPointId,
isLoadingAppPlugins: true,
pluginContext,
});
expect(actual.result).toEqual({ isLoading: false });
expect(actual.pointLog).toBeDefined();
expect(spyisExtensionPointMetaInfoMissing).not.toHaveBeenCalled();
expect(spyIsExtensionPointIdValid).toHaveBeenCalledTimes(1);
expect(spyIsExtensionPointIdValid).toHaveBeenCalledWith({
extensionPointId,
pluginId,
isInsidePlugin: true,
isCoreGrafanaPlugin: false,
log: expect.any(ExtensionsLog),
});
});
it('should return isLoading:false when extension point meta is missing', () => {
const { extensionPointId, pluginContext, pluginId, spyisExtensionPointMetaInfoMissing } = setup({
metaMissing: true,
});
const actual = validateExtensionPoint({
extensionPointId,
isLoadingAppPlugins: true,
pluginContext,
});
expect(actual.result).toEqual({ isLoading: false });
expect(actual.pointLog).toBeDefined();
expect(spyisExtensionPointMetaInfoMissing).toHaveBeenCalled();
expect(spyisExtensionPointMetaInfoMissing).toHaveBeenCalledWith(extensionPointId, pluginContext);
expect(errorSpy).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(errors.EXTENSION_POINT_META_INFO_MISSING, { extensionPointId, pluginId });
});
it('should ignore core plugins when extension point meta is missing', () => {
const { extensionPointId, pluginContext, spyisExtensionPointMetaInfoMissing } = setup({
metaMissing: true,
corePlugin: true,
});
const actual = validateExtensionPoint({
extensionPointId,
isLoadingAppPlugins: false,
pluginContext,
});
expect(actual.result).toEqual(null);
expect(actual.pointLog).toBeDefined();
expect(spyisExtensionPointMetaInfoMissing).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
it('should return isLoading:true while loading app plugins', () => {
const { extensionPointId, pluginContext } = setup();
const actual = validateExtensionPoint({
extensionPointId,
isLoadingAppPlugins: true,
pluginContext,
});
expect(actual.result).toEqual({ isLoading: true });
expect(actual.pointLog).toBeDefined();
});
it('should return null when all validations pass', () => {
const { extensionPointId, pluginContext } = setup();
const actual = validateExtensionPoint({
extensionPointId,
isLoadingAppPlugins: false,
pluginContext,
});
expect(actual.result).toBe(null);
expect(actual.pointLog).toBeDefined();
});
});
});
@@ -0,0 +1,57 @@
import { PluginContextType } from '@grafana/data';
import * as errors from './errors';
import { ExtensionsLog, log } from './logs/log';
import { isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
interface ValidateExtensionPointOptions {
extensionPointId: string;
isLoadingAppPlugins: boolean;
pluginContext: PluginContextType | null;
}
interface ValidateExtensionPoint {
isLoading: boolean;
}
type ValidateExtensionPointResult = {
result: ValidateExtensionPoint | null;
pointLog: ExtensionsLog;
};
export function validateExtensionPoint({
extensionPointId,
isLoadingAppPlugins,
pluginContext,
}: ValidateExtensionPointOptions): ValidateExtensionPointResult {
const isInsidePlugin = Boolean(pluginContext);
const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false;
const pluginId = pluginContext?.meta.id ?? '';
const pointLog = log.child({ pluginId, extensionPointId });
// Don't show extensions if the extension-point id is invalid in DEV mode
if (
isGrafanaDevMode() &&
!isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog })
) {
return { result: { isLoading: false }, pointLog };
}
// Don't show extensions if the extension-point misses meta info (plugin.json) in DEV mode
if (
isGrafanaDevMode() &&
!isCoreGrafanaPlugin &&
pluginContext &&
isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)
) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return { result: { isLoading: false }, pointLog };
}
if (isLoadingAppPlugins) {
return { result: { isLoading: true }, pointLog };
}
return { result: null, pointLog };
}