Plugins: replaces various plugin imports with pluginImporter (#108002)

* Plugins: renames plugin_loader

* Wip

* chore: adds pluginImporter

* chore: some small refactors

* chore: better typings

* chore: merge functions

* chore: create a generic plugin cache

* chore: adds comments

* chore: change to const

* chore: remove unused change

* chore: put everything behind feature toggle

* chore: rename test file and props

* chore: adds sync cache as well

* chore: fix the typings

* chore: fix broken unit test

* chore: small rename

* chore: adds tests

* chore: updates after PR feedback

* chore: updates after PR feedback
This commit is contained in:
Hugo Häggmark
2025-07-16 06:42:28 +02:00
committed by GitHub
parent 332601320c
commit 5b82e05697
15 changed files with 1054 additions and 327 deletions
@@ -1021,4 +1021,9 @@ export interface FeatureToggles {
* @default false
*/
foldersAppPlatformAPI?: boolean;
/**
* Set this to true to use the new PluginImporter functionality
* @default false
*/
enablePluginImporter?: boolean;
}
+10
View File
@@ -1755,6 +1755,16 @@ var (
FrontendOnly: true,
Expression: "false",
},
{
Name: "enablePluginImporter",
Description: "Set this to true to use the new PluginImporter functionality",
Stage: FeatureStageExperimental,
Owner: grafanaPluginsPlatformSquad,
HideFromAdminPage: true,
HideFromDocs: true,
FrontendOnly: true,
Expression: "false",
},
}
)
+1
View File
@@ -228,3 +228,4 @@ tabularNumbers,GA,@grafana/grafana-frontend-platform,false,false,false
newInfluxDSConfigPageDesign,privatePreview,@grafana/partner-datasources,false,false,false
enableAppChromeExtensions,experimental,@grafana/plugins-platform-backend,false,false,true
foldersAppPlatformAPI,experimental,@grafana/grafana-search-navigate-organise,false,false,true
enablePluginImporter,experimental,@grafana/plugins-platform-backend,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
228 newInfluxDSConfigPageDesign privatePreview @grafana/partner-datasources false false false
229 enableAppChromeExtensions experimental @grafana/plugins-platform-backend false false true
230 foldersAppPlatformAPI experimental @grafana/grafana-search-navigate-organise false false true
231 enablePluginImporter experimental @grafana/plugins-platform-backend false false true
+4
View File
@@ -922,4 +922,8 @@ const (
// FlagFoldersAppPlatformAPI
// Enables use of app platform API for folders
FlagFoldersAppPlatformAPI = "foldersAppPlatformAPI"
// FlagEnablePluginImporter
// Set this to true to use the new PluginImporter functionality
FlagEnablePluginImporter = "enablePluginImporter"
)
+33
View File
@@ -1003,6 +1003,22 @@
"hideFromAdminPage": true
}
},
{
"metadata": {
"name": "enablePluginImporter",
"resourceVersion": "1752591728321",
"creationTimestamp": "2025-07-15T15:02:08Z"
},
"spec": {
"description": "Set this to true to use the new PluginImporter functionality",
"stage": "experimental",
"codeowner": "@grafana/plugins-platform-backend",
"frontend": true,
"hideFromAdminPage": true,
"hideFromDocs": true,
"expression": "false"
}
},
{
"metadata": {
"name": "enableSCIM",
@@ -2317,6 +2333,23 @@
"requiresRestart": true
}
},
{
"metadata": {
"name": "pluginLoadingRefactor",
"resourceVersion": "1752218524617",
"creationTimestamp": "2025-07-11T07:22:04Z",
"deletionTimestamp": "2025-07-11T07:23:34Z"
},
"spec": {
"description": "Set this to true to use the new plugin loading functionality",
"stage": "experimental",
"codeowner": "@grafana/plugins-platform-backend",
"frontend": true,
"hideFromAdminPage": true,
"hideFromDocs": true,
"expression": "false"
}
},
{
"metadata": {
"name": "pluginProxyPreserveTrailingSlash",
@@ -3,7 +3,8 @@ import config from 'app/core/config';
import { getPanelPluginLoadError } from '../panel/components/PanelPluginError';
import { importPluginModule } from './pluginLoader';
import { importPluginModule } from './importer/importPluginModule';
import { pluginImporter } from './importer/pluginImporter';
const promiseCache: Record<string, Promise<PanelPlugin>> = {};
const panelPluginCache: Record<string, PanelPlugin> = {};
@@ -50,10 +51,18 @@ export function importPanelPluginFromMeta(meta: PanelPluginMeta): Promise<PanelP
}
export function syncGetPanelPlugin(id: string): PanelPlugin | undefined {
if (config.featureToggles.enablePluginImporter) {
return pluginImporter.getPanel(id);
}
return panelPluginCache[id];
}
function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
if (config.featureToggles.enablePluginImporter) {
return pluginImporter.importPanel(meta);
}
throwIfAngular(meta);
const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
@@ -0,0 +1,142 @@
import { i18n } from 'i18next';
import * as i18nModule from '@grafana/i18n/internal';
import { server } from '../loader/pluginLoader.mock';
import { SystemJS } from '../loader/systemjs';
import { SystemJSWithLoaderHooks } from '../loader/types';
import { addTranslationsToI18n } from './addTranslationsToI18n';
describe('addTranslationsToI18n', () => {
const systemJSPrototype: SystemJSWithLoaderHooks = SystemJS.constructor.prototype;
const originalFetch = systemJSPrototype.fetch;
const originalResolve = systemJSPrototype.resolve;
let addResourceBundleSpy: jest.SpyInstance;
beforeAll(() => {
server.listen();
systemJSPrototype.resolve = (moduleId: string) => moduleId;
systemJSPrototype.shouldFetch = () => true;
// because server.listen() patches fetch, we need to reassign this to the systemJSPrototype
// this is identical to what happens in the original code: https://github.com/systemjs/systemjs/blob/main/src/features/fetch-load.js#L12
systemJSPrototype.fetch = window.fetch;
});
beforeEach(() => {
addResourceBundleSpy = jest
.spyOn(i18nModule, 'addResourceBundle')
.mockImplementation(() => ({}) as unknown as i18n);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
afterAll(() => {
SystemJS.constructor.prototype.resolve = originalResolve;
SystemJS.constructor.prototype.fetch = originalFetch;
server.close();
});
it('should add translations that match the resolved language first', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json',
};
await addTranslationsToI18n({
resolvedLanguage: 'pt-BR',
fallbackLanguage: 'en-US',
pluginId: 'test-panel',
translations,
});
expect(addResourceBundleSpy).toHaveBeenCalledTimes(1);
expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'pt-BR', 'test-panel', { testKey: 'valorDeTeste' });
});
it('should add translations that match the fallback language if the resolved language is not in the translations', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json',
};
await addTranslationsToI18n({
resolvedLanguage: 'sv-SE',
fallbackLanguage: 'en-US',
pluginId: 'test-panel',
translations,
});
expect(addResourceBundleSpy).toHaveBeenCalledTimes(1);
expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'en-US', 'test-panel', { testKey: 'testValue' });
});
it('should warn if no translations are found', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json',
};
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await addTranslationsToI18n({
resolvedLanguage: 'sv-SE',
fallbackLanguage: 'sv-SE',
pluginId: 'test-panel',
translations,
});
expect(consoleSpy).toHaveBeenCalledWith('Could not find any translation for plugin test-panel', {
resolvedLanguage: 'sv-SE',
fallbackLanguage: 'sv-SE',
});
});
it('should warn if no exported default is found', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/no-default-export.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/no-default-export.json',
};
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await addTranslationsToI18n({
resolvedLanguage: 'en-US',
fallbackLanguage: 'en-US',
pluginId: 'test-panel',
translations,
});
expect(consoleSpy).toHaveBeenCalledWith('Could not find default export for plugin test-panel', {
resolvedLanguage: 'en-US',
fallbackLanguage: 'en-US',
path: '/public/plugins/test-panel/locales/en-US/no-default-export.json',
});
});
it('should warn if translations cannot be loaded', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/unknown.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/unknown.json',
};
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await addTranslationsToI18n({
resolvedLanguage: 'en-US',
fallbackLanguage: 'pt-BR',
pluginId: 'test-panel',
translations,
});
expect(consoleSpy).toHaveBeenCalledWith('Could not load translation for plugin test-panel', {
resolvedLanguage: 'en-US',
fallbackLanguage: 'pt-BR',
error: new TypeError('Failed to fetch'),
path: '/public/plugins/test-panel/locales/en-US/unknown.json',
});
});
});
@@ -0,0 +1,49 @@
import { addResourceBundle } from '@grafana/i18n/internal';
import { SystemJS } from '../loader/systemjs';
import { resolveModulePath } from '../loader/utils';
interface AddTranslationsToI18nOptions {
resolvedLanguage: string;
fallbackLanguage: string;
pluginId: string;
translations: Record<string, string>;
}
export async function addTranslationsToI18n({
resolvedLanguage,
fallbackLanguage,
pluginId,
translations,
}: AddTranslationsToI18nOptions): Promise<void> {
const resolvedPath = translations[resolvedLanguage];
const fallbackPath = translations[fallbackLanguage];
const path = resolvedPath ?? fallbackPath;
if (!path) {
console.warn(`Could not find any translation for plugin ${pluginId}`, { resolvedLanguage, fallbackLanguage });
return;
}
try {
const module = await SystemJS.import(resolveModulePath(path));
if (!module.default) {
console.warn(`Could not find default export for plugin ${pluginId}`, {
resolvedLanguage,
fallbackLanguage,
path,
});
return;
}
const language = resolvedPath ? resolvedLanguage : fallbackLanguage;
addResourceBundle(language, pluginId, module.default);
} catch (error) {
console.warn(`Could not load translation for plugin ${pluginId}`, {
resolvedLanguage,
fallbackLanguage,
error,
path,
});
}
}
@@ -0,0 +1,83 @@
import { DEFAULT_LANGUAGE } from '@grafana/i18n';
import { getResolvedLanguage } from '@grafana/i18n/internal';
import { config } from '@grafana/runtime';
import builtInPlugins from '../built_in_plugins';
import { registerPluginInCache } from '../loader/cache';
import { SystemJS } from '../loader/systemjs';
import { resolveModulePath } from '../loader/utils';
import { importPluginModuleInSandbox } from '../sandbox/sandboxPluginLoader';
import { shouldLoadPluginInFrontendSandbox } from '../sandbox/sandboxPluginLoaderRegistry';
import { pluginsLogger } from '../utils';
import { addTranslationsToI18n } from './addTranslationsToI18n';
import { PluginImportInfo } from './types';
export async function importPluginModule({
path,
pluginId,
loadingStrategy,
version,
moduleHash,
translations,
}: PluginImportInfo): Promise<System.Module> {
if (version) {
registerPluginInCache({ path, version, loadingStrategy });
}
// Add locales to i18n for a plugin if the feature toggle is enabled and the plugin has locales
if (config.featureToggles.localizationForPlugins && translations) {
await addTranslationsToI18n({
resolvedLanguage: getResolvedLanguage(),
fallbackLanguage: DEFAULT_LANGUAGE,
pluginId,
translations,
});
}
const builtIn = builtInPlugins[path];
if (builtIn) {
// for handling dynamic imports
if (typeof builtIn === 'function') {
return await builtIn();
} else {
return builtIn;
}
}
const modulePath = resolveModulePath(path);
// inject integrity hash into SystemJS import map
if (config.featureToggles.pluginsSriChecks) {
const resolvedModule = System.resolve(modulePath);
const integrityMap = System.getImportMap().integrity;
if (moduleHash && integrityMap && !integrityMap[resolvedModule]) {
SystemJS.addImportMap({
integrity: {
[resolvedModule]: moduleHash,
},
});
}
}
// the sandboxing environment code cannot work in nodejs and requires a real browser
if (await shouldLoadPluginInFrontendSandbox({ pluginId })) {
return importPluginModuleInSandbox({ pluginId });
}
return SystemJS.import(modulePath).catch((e) => {
let error = new Error('Could not load plugin: ' + e);
console.error(error);
pluginsLogger.logError(error, {
path,
pluginId,
pluginVersion: version ?? '',
expectedHash: moduleHash ?? '',
loadingStrategy: loadingStrategy.toString(),
sriChecksEnabled: String(Boolean(config.featureToggles.pluginsSriChecks)),
newPluginLoadingEnabled: String(Boolean(config.featureToggles.enablePluginImporter)),
});
throw error;
});
}
@@ -0,0 +1,415 @@
import {
AppPlugin,
AppPluginMeta,
DataSourcePlugin,
DataSourcePluginMeta,
PanelPlugin,
PanelPluginMeta,
PluginLoadingStrategy,
PluginMeta,
PluginType,
} from '@grafana/data';
import {
addedComponentsRegistry,
addedFunctionsRegistry,
addedLinksRegistry,
exposedComponentsRegistry,
} from '../extensions/registry/setup';
import { pluginsLogger } from '../utils';
import * as importPluginModule from './importPluginModule';
import { pluginImporter, clearCaches } from './pluginImporter';
describe('pluginImporter', () => {
beforeEach(() => {
jest.clearAllMocks();
clearCaches();
});
describe('importPanel', () => {
it('should import a panel plugin successfully with fallbackLoadingStrategy', async () => {
const spy = jest
.spyOn(importPluginModule, 'importPluginModule')
.mockResolvedValue({ plugin: { ...panelPlugin } });
const result = await pluginImporter.importPanel({ ...panelPlugin });
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'fetch',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(result).toEqual({ ...panelPlugin, meta: { ...panelPlugin } });
});
it('should set correct loading strategy', async () => {
const spy = jest
.spyOn(importPluginModule, 'importPluginModule')
.mockResolvedValue({ plugin: { ...panelPlugin } });
const meta = { ...panelPlugin, loadingStrategy: PluginLoadingStrategy.script };
const result = await pluginImporter.importPanel({ ...meta });
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'script',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(result).toEqual({ ...panelPlugin, meta: { ...panelPlugin, loadingStrategy: 'script' } });
});
it('should log a warning and return a error component if module is missing exported plugin', async () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({});
const result = await pluginImporter.importPanel({ ...panelPlugin });
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'fetch',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(consoleSpy).toHaveBeenCalledWith(
'Error loading panel plugin: test-plugin',
new Error('missing export: plugin')
);
expect(result).toBeInstanceOf(PanelPlugin);
expect(result.loadError).toBe(true);
});
});
describe('importDataSource', () => {
it('should import a data source plugin successfully with fallbackLoadingStrategy', async () => {
const spy = jest
.spyOn(importPluginModule, 'importPluginModule')
.mockResolvedValue({ plugin: { ...dataSourcePlugin } });
const result = await pluginImporter.importDataSource({ ...dataSourcePlugin });
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'fetch',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(result).toEqual({ ...dataSourcePlugin, meta: { ...dataSourcePlugin } });
});
it('should import a data source plugin with Datasource prop', async () => {
const spy = jest
.spyOn(importPluginModule, 'importPluginModule')
.mockResolvedValue({ Datasource: { ...dataSourcePlugin } });
const result = await pluginImporter.importDataSource({ ...dataSourcePlugin });
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'fetch',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DataSourceClass: any = { ...dataSourcePlugin };
const expected = new DataSourcePlugin(DataSourceClass);
expect(result).toEqual({
...expected,
components: {
AnnotationsQueryCtrl: undefined,
ExploreQueryField: undefined,
QueryCtrl: undefined,
QueryEditor: undefined,
QueryEditorHelp: undefined,
VariableQueryEditor: undefined,
},
meta: { ...dataSourcePlugin },
});
});
it('should set correct loading strategy', async () => {
const spy = jest
.spyOn(importPluginModule, 'importPluginModule')
.mockResolvedValue({ plugin: { ...dataSourcePlugin } });
const meta = { ...dataSourcePlugin, loadingStrategy: PluginLoadingStrategy.script };
const result = await pluginImporter.importDataSource({ ...meta });
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'script',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(result).toEqual({ ...dataSourcePlugin, meta: { ...dataSourcePlugin, loadingStrategy: 'script' } });
});
it('should throw error if module is missing exported plugin', async () => {
const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({});
expect(async () => {
await pluginImporter.importDataSource({ ...dataSourcePlugin });
}).rejects.toThrow(new Error('Plugin module is missing DataSourcePlugin or Datasource constructor export'));
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'fetch',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
});
});
describe('importApp', () => {
it('should import a app plugin successfully with fallbackLoadingStrategy', async () => {
const init = jest.fn();
const setComponentsFromLegacyExports = jest.fn();
const plugin = {
plugin: {
...appPlugin,
init,
setComponentsFromLegacyExports,
exposedComponentConfigs: [{}],
addedComponentConfigs: [{}],
addedLinkConfigs: [{}],
addedFunctionConfigs: [{}],
},
};
const exposedComponentsRegistrySpy = jest
.spyOn(exposedComponentsRegistry, 'register')
.mockImplementation(() => {});
const addedComponentsRegistrySpy = jest.spyOn(addedComponentsRegistry, 'register').mockImplementation(() => {});
const addedLinksRegistrySpy = jest.spyOn(addedLinksRegistry, 'register').mockImplementation(() => {});
const addedFunctionsRegistrySpy = jest.spyOn(addedFunctionsRegistry, 'register').mockImplementation(() => {});
const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({ ...plugin });
const result = await pluginImporter.importApp({ ...appPlugin });
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'fetch',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(init).toHaveBeenCalledWith({ ...appPlugin });
expect(setComponentsFromLegacyExports).toHaveBeenCalledWith({ ...plugin });
expect(exposedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedLinksRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedFunctionsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(result).toEqual({
...appPlugin,
meta: { ...appPlugin },
init,
setComponentsFromLegacyExports,
exposedComponentConfigs: [{}],
addedComponentConfigs: [{}],
addedLinkConfigs: [{}],
addedFunctionConfigs: [{}],
});
});
it('should set correct loading strategy', async () => {
const init = jest.fn();
const setComponentsFromLegacyExports = jest.fn();
const plugin = {
plugin: {
...appPlugin,
init,
setComponentsFromLegacyExports,
exposedComponentConfigs: [{}],
addedComponentConfigs: [{}],
addedLinkConfigs: [{}],
addedFunctionConfigs: [{}],
},
};
const exposedComponentsRegistrySpy = jest
.spyOn(exposedComponentsRegistry, 'register')
.mockImplementation(() => {});
const addedComponentsRegistrySpy = jest.spyOn(addedComponentsRegistry, 'register').mockImplementation(() => {});
const addedLinksRegistrySpy = jest.spyOn(addedLinksRegistry, 'register').mockImplementation(() => {});
const addedFunctionsRegistrySpy = jest.spyOn(addedFunctionsRegistry, 'register').mockImplementation(() => {});
const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({ ...plugin });
const meta = { ...appPlugin, loadingStrategy: PluginLoadingStrategy.script };
const result = await pluginImporter.importApp({ ...meta });
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'script',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(init).toHaveBeenCalledWith({ ...meta });
expect(setComponentsFromLegacyExports).toHaveBeenCalledWith({ ...plugin });
expect(exposedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedLinksRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(addedFunctionsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] });
expect(result).toEqual({
...appPlugin,
meta: { ...appPlugin, loadingStrategy: 'script' },
init,
setComponentsFromLegacyExports,
exposedComponentConfigs: [{}],
addedComponentConfigs: [{}],
addedLinkConfigs: [{}],
addedFunctionConfigs: [{}],
});
});
it('should import an empty app plugin if missing exported plugin', async () => {
const exposedComponentsRegistrySpy = jest
.spyOn(exposedComponentsRegistry, 'register')
.mockImplementation(() => {});
const addedComponentsRegistrySpy = jest.spyOn(addedComponentsRegistry, 'register').mockImplementation(() => {});
const addedLinksRegistrySpy = jest.spyOn(addedLinksRegistry, 'register').mockImplementation(() => {});
const addedFunctionsRegistrySpy = jest.spyOn(addedFunctionsRegistry, 'register').mockImplementation(() => {});
const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({});
const result = await pluginImporter.importApp({ ...appPlugin });
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'fetch',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(exposedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] });
expect(addedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] });
expect(addedLinksRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] });
expect(addedFunctionsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] });
expect(result).toEqual({ ...new AppPlugin(), meta: { ...appPlugin } });
});
});
describe('caches', () => {
it('should return a cached plugin if it exsits', async () => {
const logSpy = jest.spyOn(pluginsLogger, 'logDebug').mockImplementation(() => {});
const spy = jest
.spyOn(importPluginModule, 'importPluginModule')
.mockResolvedValue({ plugin: { ...panelPlugin } });
const original = await pluginImporter.importPanel({ ...panelPlugin });
const cached = await pluginImporter.importPanel({ ...panelPlugin });
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'fetch',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(logSpy).toHaveBeenCalledWith(`Retrieving plugin from cache`, {
expectedHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
loadingStrategy: 'fetch',
newPluginLoadingEnabled: 'false',
path: 'public/plugins/test-plugin/module.js',
pluginId: 'test-plugin',
pluginVersion: '1.0.0',
sriChecksEnabled: 'false',
});
expect(cached).toBe(original);
});
it('should return an inflight plugin load if it exsits', async () => {
const logSpy = jest.spyOn(pluginsLogger, 'logDebug').mockImplementation(() => {});
const spy = jest
.spyOn(importPluginModule, 'importPluginModule')
.mockResolvedValue({ plugin: { ...panelPlugin } });
const original = pluginImporter.importPanel({ ...panelPlugin });
const cached = pluginImporter.importPanel({ ...panelPlugin });
await Promise.all([original, cached]);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith({
path: 'public/plugins/test-plugin/module.js',
version: '1.0.0',
loadingStrategy: 'fetch',
pluginId: 'test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
});
expect(logSpy).toHaveBeenCalledWith(`Retrieving plugin from inflight plugin load request`, {
expectedHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
loadingStrategy: 'fetch',
newPluginLoadingEnabled: 'false',
path: 'public/plugins/test-plugin/module.js',
pluginId: 'test-plugin',
pluginVersion: '1.0.0',
sriChecksEnabled: 'false',
});
expect(cached).toBe(original);
});
});
});
const baseMeta: PluginMeta = {
id: 'test-plugin',
name: 'Test Plugin',
type: '' as PluginType,
module: 'public/plugins/test-plugin/module.js',
baseUrl: 'public/plugins/test-plugin',
moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b',
translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' },
info: {
author: { name: 'Test Author' },
description: 'Test Description',
links: [],
logos: { large: '', small: '' },
screenshots: [],
updated: '2023-01-01',
version: '1.0.0',
},
};
const panelPlugin: PanelPluginMeta = {
...baseMeta,
type: PluginType.panel,
sort: 0,
};
const dataSourcePlugin: DataSourcePluginMeta = {
...baseMeta,
type: PluginType.datasource,
};
const appPlugin: AppPluginMeta = {
...baseMeta,
type: PluginType.app,
};
@@ -0,0 +1,185 @@
import {
AppPlugin,
AppPluginMeta,
DataQuery,
DataSourceApi,
DataSourceJsonData,
DataSourcePlugin,
DataSourcePluginMeta,
PanelPlugin,
PanelPluginMeta,
PluginLoadingStrategy,
PluginMeta,
throwIfAngular,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { GenericDataSourcePlugin } from 'app/features/datasources/types';
import { getPanelPluginLoadError } from 'app/features/panel/components/PanelPluginError';
import {
addedComponentsRegistry,
addedFunctionsRegistry,
addedLinksRegistry,
exposedComponentsRegistry,
} from '../extensions/registry/setup';
import { pluginsLogger } from '../utils';
import { importPluginModule } from './importPluginModule';
import { PluginImporter, PostImportStrategy, PreImportStrategy } from './types';
const defaultPreImport: PreImportStrategy = (plugin) => {
throwIfAngular(plugin);
const fallbackLoadingStrategy = plugin.loadingStrategy ?? PluginLoadingStrategy.fetch;
const args = {
path: plugin.module,
version: plugin.info?.version,
loadingStrategy: fallbackLoadingStrategy,
pluginId: plugin.id,
moduleHash: plugin.moduleHash,
translations: plugin.translations,
};
return args;
};
const panelPluginPostImport: PostImportStrategy<PanelPlugin, PanelPluginMeta> = async (meta, module) => {
try {
const pluginExports = await module;
if (pluginExports.plugin) {
const plugin: PanelPlugin = pluginExports.plugin;
plugin.meta = meta;
pluginsCache.set(meta.id, plugin);
return plugin;
}
throwIfAngular(pluginExports);
throw new Error('missing export: plugin');
} catch (error) {
// TODO, maybe a different error plugin
console.warn('Error loading panel plugin: ' + meta.id, error);
return getPanelPluginLoadError(meta, error);
}
};
const datasourcePluginPostImport: PostImportStrategy<GenericDataSourcePlugin, DataSourcePluginMeta> = async (
meta,
module
) => {
const pluginExports = await module;
if (pluginExports.plugin) {
const dsPlugin: GenericDataSourcePlugin = pluginExports.plugin;
dsPlugin.meta = meta;
pluginsCache.set(meta.id, dsPlugin);
return dsPlugin;
}
if (pluginExports.Datasource) {
const dsPlugin = new DataSourcePlugin<DataSourceApi<DataQuery, DataSourceJsonData>, DataQuery, DataSourceJsonData>(
pluginExports.Datasource
);
dsPlugin.setComponentsFromLegacyExports(pluginExports);
dsPlugin.meta = meta;
pluginsCache.set(meta.id, dsPlugin);
return dsPlugin;
}
throw new Error('Plugin module is missing DataSourcePlugin or Datasource constructor export');
};
const appPluginPostImport: PostImportStrategy<AppPlugin, AppPluginMeta> = async (meta, module) => {
const pluginExports = await module;
const { plugin = new AppPlugin() } = pluginExports;
plugin.init(meta);
plugin.meta = meta;
plugin.setComponentsFromLegacyExports(pluginExports);
exposedComponentsRegistry.register({ pluginId: meta.id, configs: plugin.exposedComponentConfigs || [] });
addedComponentsRegistry.register({ pluginId: meta.id, configs: plugin.addedComponentConfigs || [] });
addedLinksRegistry.register({ pluginId: meta.id, configs: plugin.addedLinkConfigs || [] });
addedFunctionsRegistry.register({ pluginId: meta.id, configs: plugin.addedFunctionConfigs || [] });
pluginsCache.set(meta.id, plugin);
return plugin;
};
const promisesCache: Map<string, Promise<PanelPlugin | GenericDataSourcePlugin | AppPlugin>> = new Map();
const getPromiseFromCache = <M extends PluginMeta, P extends PanelPlugin | GenericDataSourcePlugin | AppPlugin>(
meta: M
): Promise<P> => {
const cached = promisesCache.get(meta.id);
if (cached) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return cached as Promise<P>;
}
throw new Error(`Trying to get unknown plugin type ${meta.type} from cache for plugin ${meta.id}`);
};
const pluginsCache: Map<string, PanelPlugin | GenericDataSourcePlugin | AppPlugin> = new Map();
const getPluginFromCache = <P extends PanelPlugin | GenericDataSourcePlugin | AppPlugin>(id: string): P | undefined => {
const cached = pluginsCache.get(id);
if (cached) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return cached as P;
}
return undefined;
};
const importPlugin = <M extends PluginMeta, P extends PanelPlugin | GenericDataSourcePlugin | AppPlugin>(
meta: M,
postImportStrategy: PostImportStrategy<P, M>,
preImportStrategy: PreImportStrategy<M> = defaultPreImport
): Promise<P> => {
const cached = getPluginFromCache<P>(meta.id);
if (cached) {
pluginsLogger.logDebug(`Retrieving plugin from cache`, {
path: meta.module,
pluginId: meta.id,
pluginVersion: meta.info?.version ?? '',
expectedHash: meta.moduleHash ?? '',
loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch,
sriChecksEnabled: String(Boolean(config.featureToggles.pluginsSriChecks)),
newPluginLoadingEnabled: String(Boolean(config.featureToggles.enablePluginImporter)),
});
return Promise.resolve(cached);
}
if (promisesCache.has(meta.id)) {
pluginsLogger.logDebug(`Retrieving plugin from inflight plugin load request`, {
path: meta.module,
pluginId: meta.id,
pluginVersion: meta.info?.version ?? '',
expectedHash: meta.moduleHash ?? '',
loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch,
sriChecksEnabled: String(Boolean(config.featureToggles.pluginsSriChecks)),
newPluginLoadingEnabled: String(Boolean(config.featureToggles.enablePluginImporter)),
});
return getPromiseFromCache(meta);
}
const args = preImportStrategy(meta);
const module = importPluginModule(args);
const plugin = postImportStrategy(meta, module);
promisesCache.set(meta.id, plugin);
return getPromiseFromCache(meta);
};
export const pluginImporter: PluginImporter = {
importPanel: (meta: PanelPluginMeta) => importPlugin(meta, panelPluginPostImport),
importDataSource: (meta: DataSourcePluginMeta) => importPlugin(meta, datasourcePluginPostImport),
importApp: (meta: AppPluginMeta) => importPlugin(meta, appPluginPostImport),
getPanel: (id: string) => getPluginFromCache<PanelPlugin>(id), // we need this sync because how the panel plugins are loaded in PanelRenderer
};
export const clearCaches = () => {
promisesCache.clear();
pluginsCache.clear();
};
@@ -0,0 +1,53 @@
import {
AppPlugin,
AppPluginMeta,
DataSourcePluginMeta,
PanelPlugin,
PanelPluginMeta,
PluginLoadingStrategy,
PluginMeta,
} from '@grafana/data';
import { GenericDataSourcePlugin } from 'app/features/datasources/types';
export interface PluginImporter {
/**
* Imports a panel plugin from module.js
* @param meta - The plugin meta
* @returns a Promise<PanelPlugin>
*/
importPanel: (meta: PanelPluginMeta) => Promise<PanelPlugin>;
/**
* Imports a datasource plugin from module.js
* @param meta - The plugin meta
* @returns a Promise<GenericDataSourcePlugin>
*/
importDataSource: (meta: DataSourcePluginMeta) => Promise<GenericDataSourcePlugin>;
/**
* Imports an app plugin from module.js
* @param meta - The plugin meta
* @returns a Promise<AppPlugin>
*/
importApp: (meta: AppPluginMeta) => Promise<AppPlugin>;
/**
* Retrieves a panel plugin from the cache, if it doesn't exist in the cache it returns undefined
* @param id - The plugin id
* @returns a PanelPlugin or undefined
*/
getPanel: (id: string) => PanelPlugin | undefined;
}
export interface PluginImportInfo {
path: string;
pluginId: string;
loadingStrategy: PluginLoadingStrategy;
version?: string;
moduleHash?: string;
translations?: Record<string, string>;
}
export type PreImportStrategy<M extends PluginMeta = PluginMeta> = (meta: M) => PluginImportInfo;
export type PostImportStrategy<
P extends PanelPlugin | GenericDataSourcePlugin | AppPlugin,
M extends PluginMeta = PluginMeta,
> = (meta: M, module: Promise<System.Module>) => Promise<P>;
+52 -129
View File
@@ -1,143 +1,66 @@
import { i18n } from 'i18next';
jest.mock('app/core/core', () => {
return {
coreModule: {
directive: jest.fn(),
},
};
});
import * as i18nModule from '@grafana/i18n/internal';
import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data';
import { server } from './loader/pluginLoader.mock';
// Loaded after the `unmock` above
import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup';
import { SystemJS } from './loader/systemjs';
import { SystemJSWithLoaderHooks } from './loader/types';
import { addTranslationsToI18n } from './pluginLoader';
import { importAppPlugin } from './pluginLoader';
describe('pluginLoader', () => {
describe('addTranslationsToI18n', () => {
const systemJSPrototype: SystemJSWithLoaderHooks = SystemJS.constructor.prototype;
const originalFetch = systemJSPrototype.fetch;
const originalResolve = systemJSPrototype.resolve;
let addResourceBundleSpy: jest.SpyInstance;
jest.mock('./extensions/registry/setup');
beforeAll(() => {
server.listen();
systemJSPrototype.resolve = (moduleId: string) => moduleId;
systemJSPrototype.shouldFetch = () => true;
// because server.listen() patches fetch, we need to reassign this to the systemJSPrototype
// this is identical to what happens in the original code: https://github.com/systemjs/systemjs/blob/main/src/features/fetch-load.js#L12
systemJSPrototype.fetch = window.fetch;
});
describe('Load App', () => {
const app = new AppPlugin();
const modulePath = 'http://localhost:3000/public/plugins/my-app-plugin/module.js';
// Hook resolver for tests
const originalResolve = SystemJS.constructor.prototype.resolve;
SystemJS.constructor.prototype.resolve = (x: unknown) => x;
beforeEach(() => {
addResourceBundleSpy = jest
.spyOn(i18nModule, 'addResourceBundle')
.mockImplementation(() => ({}) as unknown as i18n);
});
beforeAll(() => {
app.init = jest.fn();
addedComponentsRegistry.register = jest.fn();
addedLinksRegistry.register = jest.fn();
exposedComponentsRegistry.register = jest.fn();
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
SystemJS.set(modulePath, { plugin: app });
});
afterAll(() => {
SystemJS.constructor.prototype.resolve = originalResolve;
SystemJS.constructor.prototype.fetch = originalFetch;
server.close();
});
afterAll(() => {
SystemJS.delete(modulePath);
SystemJS.constructor.prototype.resolve = originalResolve;
});
it('should add translations that match the resolved language first', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json',
};
it('should call init and set meta', async () => {
const meta: AppPluginMeta = {
id: 'test-app',
module: modulePath,
baseUrl: 'xxx',
info: {} as PluginMetaInfo,
type: PluginType.app,
name: 'test',
};
await addTranslationsToI18n({
resolvedLanguage: 'pt-BR',
fallbackLanguage: 'en-US',
pluginId: 'test-panel',
translations,
});
// Check that we mocked the import OK
const m = await SystemJS.import(modulePath);
expect(m.plugin).toBe(app);
expect(addResourceBundleSpy).toHaveBeenCalledTimes(1);
expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'pt-BR', 'test-panel', { testKey: 'valorDeTeste' });
});
// Importing the app should initialise the meta
const importedApp = await importAppPlugin(meta);
expect(importedApp).toBe(app);
expect(app.meta).toBe(meta);
it('should add translations that match the fallback language if the resolved language is not in the translations', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json',
};
await addTranslationsToI18n({
resolvedLanguage: 'sv-SE',
fallbackLanguage: 'en-US',
pluginId: 'test-panel',
translations,
});
expect(addResourceBundleSpy).toHaveBeenCalledTimes(1);
expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'en-US', 'test-panel', { testKey: 'testValue' });
});
it('should warn if no translations are found', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json',
};
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await addTranslationsToI18n({
resolvedLanguage: 'sv-SE',
fallbackLanguage: 'sv-SE',
pluginId: 'test-panel',
translations,
});
expect(consoleSpy).toHaveBeenCalledWith('Could not find any translation for plugin test-panel', {
resolvedLanguage: 'sv-SE',
fallbackLanguage: 'sv-SE',
});
});
it('should warn if no exported default is found', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/no-default-export.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/no-default-export.json',
};
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await addTranslationsToI18n({
resolvedLanguage: 'en-US',
fallbackLanguage: 'en-US',
pluginId: 'test-panel',
translations,
});
expect(consoleSpy).toHaveBeenCalledWith('Could not find default export for plugin test-panel', {
resolvedLanguage: 'en-US',
fallbackLanguage: 'en-US',
path: '/public/plugins/test-panel/locales/en-US/no-default-export.json',
});
});
it('should warn if translations cannot be loaded', async () => {
const translations = {
'en-US': '/public/plugins/test-panel/locales/en-US/unknown.json',
'pt-BR': '/public/plugins/test-panel/locales/pt-BR/unknown.json',
};
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await addTranslationsToI18n({
resolvedLanguage: 'en-US',
fallbackLanguage: 'pt-BR',
pluginId: 'test-panel',
translations,
});
expect(consoleSpy).toHaveBeenCalledWith('Could not load translation for plugin test-panel', {
resolvedLanguage: 'en-US',
fallbackLanguage: 'pt-BR',
error: new TypeError('Failed to fetch'),
path: '/public/plugins/test-panel/locales/en-US/unknown.json',
});
});
// Importing the same app again doesn't initialise it twice
const importedAppAgain = await importAppPlugin(meta);
expect(importedAppAgain).toBe(app);
expect(app.init).toHaveBeenCalledTimes(1);
expect(addedComponentsRegistry.register).toHaveBeenCalledTimes(1);
expect(addedLinksRegistry.register).toHaveBeenCalledTimes(1);
expect(exposedComponentsRegistry.register).toHaveBeenCalledTimes(1);
});
});
+12 -131
View File
@@ -8,31 +8,27 @@ import {
PluginMeta,
throwIfAngular,
} from '@grafana/data';
import { DEFAULT_LANGUAGE } from '@grafana/i18n';
import { addResourceBundle, getResolvedLanguage } from '@grafana/i18n/internal';
import { config } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { GenericDataSourcePlugin } from '../datasources/types';
import builtInPlugins from './built_in_plugins';
import {
addedComponentsRegistry,
addedFunctionsRegistry,
addedLinksRegistry,
exposedComponentsRegistry,
} from './extensions/registry/setup';
import { getPluginFromCache, registerPluginInCache } from './loader/cache';
import { importPluginModule } from './importer/importPluginModule';
import { pluginImporter } from './importer/pluginImporter';
import { getPluginFromCache } from './loader/cache';
// SystemJS has to be imported before the sharedDependenciesMap
import { SystemJS } from './loader/systemjs';
// eslint-disable-next-line import/order
import { sharedDependenciesMap } from './loader/sharedDependencies';
import { decorateSystemJSFetch, decorateSystemJSResolve, decorateSystemJsOnload } from './loader/systemjsHooks';
import { SystemJSWithLoaderHooks } from './loader/types';
import { buildImportMap, resolveModulePath } from './loader/utils';
import { importPluginModuleInSandbox } from './sandbox/sandboxPluginLoader';
import { shouldLoadPluginInFrontendSandbox } from './sandbox/sandboxPluginLoaderRegistry';
import { pluginsLogger } from './utils';
import { buildImportMap } from './loader/utils';
const imports = buildImportMap(sharedDependenciesMap);
@@ -78,84 +74,11 @@ systemJSPrototype.resolve = decorateSystemJSResolve.bind(systemJSPrototype, syst
// Any css files loaded via SystemJS have their styles applied onload.
systemJSPrototype.onload = decorateSystemJsOnload;
type PluginImportInfo = {
path: string;
pluginId: string;
loadingStrategy: PluginLoadingStrategy;
version?: string;
moduleHash?: string;
translations?: Record<string, string>;
};
export async function importPluginModule({
path,
pluginId,
loadingStrategy,
version,
moduleHash,
translations,
}: PluginImportInfo): Promise<System.Module> {
if (version) {
registerPluginInCache({ path, version, loadingStrategy });
}
// Add locales to i18n for a plugin if the feature toggle is enabled and the plugin has locales
if (config.featureToggles.localizationForPlugins && translations) {
await addTranslationsToI18n({
resolvedLanguage: getResolvedLanguage(),
fallbackLanguage: DEFAULT_LANGUAGE,
pluginId,
translations,
});
}
const builtIn = builtInPlugins[path];
if (builtIn) {
// for handling dynamic imports
if (typeof builtIn === 'function') {
return await builtIn();
} else {
return builtIn;
}
}
const modulePath = resolveModulePath(path);
// inject integrity hash into SystemJS import map
if (config.featureToggles.pluginsSriChecks) {
const resolvedModule = System.resolve(modulePath);
const integrityMap = System.getImportMap().integrity;
if (moduleHash && integrityMap && !integrityMap[resolvedModule]) {
SystemJS.addImportMap({
integrity: {
[resolvedModule]: moduleHash,
},
});
}
}
// the sandboxing environment code cannot work in nodejs and requires a real browser
if (await shouldLoadPluginInFrontendSandbox({ pluginId })) {
return importPluginModuleInSandbox({ pluginId });
}
return SystemJS.import(modulePath).catch((e) => {
let error = new Error('Could not load plugin: ' + e);
console.error(error);
pluginsLogger.logError(error, {
path,
pluginId,
pluginVersion: version ?? '',
expectedHash: moduleHash ?? '',
loadingStrategy: loadingStrategy.toString(),
sriChecksEnabled: (config.featureToggles.pluginsSriChecks ?? false).toString(),
});
throw error;
});
}
export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<GenericDataSourcePlugin> {
if (config.featureToggles.enablePluginImporter) {
return pluginImporter.importDataSource(meta);
}
throwIfAngular(meta);
const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
@@ -191,6 +114,10 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<Gene
const importPromises: Record<string, Promise<AppPlugin>> = {};
export async function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
if (config.featureToggles.enablePluginImporter) {
return pluginImporter.importApp(meta);
}
const pluginId = meta.id;
// We are caching the import promises to prevent duplicate imports
@@ -237,49 +164,3 @@ async function doImportAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
return plugin;
}
interface AddTranslationsToI18nOptions {
resolvedLanguage: string;
fallbackLanguage: string;
pluginId: string;
translations: Record<string, string>;
}
// exported for testing purposes only
export async function addTranslationsToI18n({
resolvedLanguage,
fallbackLanguage,
pluginId,
translations,
}: AddTranslationsToI18nOptions): Promise<void> {
const resolvedPath = translations[resolvedLanguage];
const fallbackPath = translations[fallbackLanguage];
const path = resolvedPath ?? fallbackPath;
if (!path) {
console.warn(`Could not find any translation for plugin ${pluginId}`, { resolvedLanguage, fallbackLanguage });
return;
}
try {
const module = await SystemJS.import(resolveModulePath(path));
if (!module.default) {
console.warn(`Could not find default export for plugin ${pluginId}`, {
resolvedLanguage,
fallbackLanguage,
path,
});
return;
}
const language = resolvedPath ? resolvedLanguage : fallbackLanguage;
addResourceBundle(language, pluginId, module.default);
} catch (error) {
console.warn(`Could not load translation for plugin ${pluginId}`, {
resolvedLanguage,
fallbackLanguage,
error,
path,
});
}
}
@@ -1,66 +0,0 @@
jest.mock('app/core/core', () => {
return {
coreModule: {
directive: jest.fn(),
},
};
});
import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data';
// Loaded after the `unmock` above
import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from '../extensions/registry/setup';
import { SystemJS } from '../loader/systemjs';
import { importAppPlugin } from '../pluginLoader';
jest.mock('../extensions/registry/setup');
describe('Load App', () => {
const app = new AppPlugin();
const modulePath = 'http://localhost:3000/public/plugins/my-app-plugin/module.js';
// Hook resolver for tests
const originalResolve = SystemJS.constructor.prototype.resolve;
SystemJS.constructor.prototype.resolve = (x: unknown) => x;
beforeAll(() => {
app.init = jest.fn();
addedComponentsRegistry.register = jest.fn();
addedLinksRegistry.register = jest.fn();
exposedComponentsRegistry.register = jest.fn();
SystemJS.set(modulePath, { plugin: app });
});
afterAll(() => {
SystemJS.delete(modulePath);
SystemJS.constructor.prototype.resolve = originalResolve;
});
it('should call init and set meta', async () => {
const meta: AppPluginMeta = {
id: 'test-app',
module: modulePath,
baseUrl: 'xxx',
info: {} as PluginMetaInfo,
type: PluginType.app,
name: 'test',
};
// Check that we mocked the import OK
const m = await SystemJS.import(modulePath);
expect(m.plugin).toBe(app);
// Importing the app should initialise the meta
const importedApp = await importAppPlugin(meta);
expect(importedApp).toBe(app);
expect(app.meta).toBe(meta);
// Importing the same app again doesn't initialise it twice
const importedAppAgain = await importAppPlugin(meta);
expect(importedAppAgain).toBe(app);
expect(app.init).toHaveBeenCalledTimes(1);
expect(addedComponentsRegistry.register).toHaveBeenCalledTimes(1);
expect(addedLinksRegistry.register).toHaveBeenCalledTimes(1);
expect(exposedComponentsRegistry.register).toHaveBeenCalledTimes(1);
});
});