From 33775357d607ed299fea5a928c6f8eadc2ca480a Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Thu, 7 Mar 2019 16:54:22 -0600 Subject: [PATCH] Add PluginsService --- src/core/public/core_system.test.ts | 84 +++++--- src/core/public/core_system.ts | 55 +++-- src/core/public/index.ts | 4 +- .../notifications/notifications_service.ts | 37 +++- .../toasts/toasts_service.test.tsx | 13 +- .../notifications/toasts/toasts_service.tsx | 44 ++-- src/core/public/plugins/index.ts | 22 ++ src/core/public/plugins/plugin.test.ts | 109 ++++++++++ src/core/public/plugins/plugin.ts | 115 +++++++++++ src/core/public/plugins/plugin_context.ts | 74 +++++++ src/core/public/plugins/plugin_loader.mock.ts | 33 +++ src/core/public/plugins/plugin_loader.test.ts | 117 +++++++++++ src/core/public/plugins/plugin_loader.ts | 98 +++++++++ .../public/plugins/plugins_service.mock.ts | 44 ++++ .../public/plugins/plugins_service.test.ts | 193 ++++++++++++++++++ src/core/public/plugins/plugins_service.ts | 101 +++++++++ .../tests_bundle/tests_entry_template.js | 1 + .../ui/ui_bundles/app_entry_template.js | 12 +- 18 files changed, 1074 insertions(+), 82 deletions(-) create mode 100644 src/core/public/plugins/index.ts create mode 100644 src/core/public/plugins/plugin.test.ts create mode 100644 src/core/public/plugins/plugin.ts create mode 100644 src/core/public/plugins/plugin_context.ts create mode 100644 src/core/public/plugins/plugin_loader.mock.ts create mode 100644 src/core/public/plugins/plugin_loader.test.ts create mode 100644 src/core/public/plugins/plugin_loader.ts create mode 100644 src/core/public/plugins/plugins_service.mock.ts create mode 100644 src/core/public/plugins/plugins_service.test.ts create mode 100644 src/core/public/plugins/plugins_service.ts diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 9d9620e3b0853e..677bb32650e0de 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -17,6 +17,9 @@ * under the License. */ +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; + import { basePathServiceMock } from './base_path/base_path_service.mock'; import { chromeServiceMock } from './chrome/chrome_service.mock'; import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; @@ -25,6 +28,7 @@ import { i18nServiceMock } from './i18n/i18n_service.mock'; import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock'; import { legacyPlatformServiceMock } from './legacy/legacy_service.mock'; import { notificationServiceMock } from './notifications/notifications_service.mock'; +import { pluginsServiceMock } from './plugins/plugins_service.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; const MockLegacyPlatformService = legacyPlatformServiceMock.create(); @@ -85,6 +89,12 @@ jest.mock('./chrome', () => ({ ChromeService: ChromeServiceConstructor, })); +const MockPluginsService = pluginsServiceMock.create(); +const PluginsServiceConstructor = jest.fn().mockImplementation(() => MockPluginsService); +jest.mock('./plugins', () => ({ + PluginsService: PluginsServiceConstructor, +})); + import { CoreSystem } from './core_system'; jest.spyOn(CoreSystem.prototype, 'stop'); @@ -160,7 +170,7 @@ describe('constructor', () => { expect(NotificationServiceConstructor).toHaveBeenCalledTimes(1); expect(NotificationServiceConstructor).toHaveBeenCalledWith({ - targetDomElement: expect.any(HTMLElement), + targetDomElement$: expect.any(Observable), }); }); @@ -244,15 +254,15 @@ describe('#stop', () => { expect(MockI18nService.stop).toHaveBeenCalled(); }); - it('clears the rootDomElement', () => { + it('clears the rootDomElement', async () => { const rootDomElement = document.createElement('div'); const coreSystem = createCoreSystem({ rootDomElement, }); - coreSystem.setup(); + await coreSystem.setup(); expect(rootDomElement.innerHTML).not.toBe(''); - coreSystem.stop(); + await coreSystem.stop(); expect(rootDomElement.innerHTML).toBe(''); }); }); @@ -260,63 +270,68 @@ describe('#stop', () => { describe('#setup()', () => { function setupCore(rootDomElement = defaultCoreSystemParams.rootDomElement) { const core = createCoreSystem({ + ...defaultCoreSystemParams, rootDomElement, }); return core.setup(); } - it('clears the children of the rootDomElement and appends container for legacyPlatform and notifications', () => { + it('clears the children of the rootDomElement and appends container for legacyPlatform and notifications', async () => { const root = document.createElement('div'); root.innerHTML = '

foo bar

'; - setupCore(root); + await setupCore(root); expect(root.innerHTML).toBe('
'); }); - it('calls injectedMetadata#setup()', () => { - setupCore(); - + it('calls injectedMetadata#setup()', async () => { + await setupCore(); expect(MockInjectedMetadataService.setup).toHaveBeenCalledTimes(1); }); - it('calls http#setup()', () => { - setupCore(); + it('calls http#setup()', async () => { + await setupCore(); expect(MockHttpService.setup).toHaveBeenCalledTimes(1); }); - it('calls basePath#setup()', () => { - setupCore(); + it('calls basePath#setup()', async () => { + await setupCore(); expect(MockBasePathService.setup).toHaveBeenCalledTimes(1); }); - it('calls uiSettings#setup()', () => { - setupCore(); + it('calls uiSettings#setup()', async () => { + await setupCore(); expect(MockUiSettingsService.setup).toHaveBeenCalledTimes(1); }); - it('calls i18n#setup()', () => { - setupCore(); + it('calls i18n#setup()', async () => { + await setupCore(); expect(MockI18nService.setup).toHaveBeenCalledTimes(1); }); - it('calls fatalErrors#setup()', () => { - setupCore(); + it('calls fatalErrors#setup()', async () => { + await setupCore(); expect(MockFatalErrorsService.setup).toHaveBeenCalledTimes(1); }); - it('calls notifications#setup()', () => { - setupCore(); + it('calls notifications#setup()', async () => { + await setupCore(); expect(MockNotificationsService.setup).toHaveBeenCalledTimes(1); }); - it('calls chrome#setup()', () => { - setupCore(); + it('calls chrome#setup()', async () => { + await setupCore(); expect(MockChromeService.setup).toHaveBeenCalledTimes(1); }); + + it('calls plugin#setup()', async () => { + await setupCore(); + expect(MockPluginsService.setup).toHaveBeenCalledTimes(1); + }); }); describe('LegacyPlatform targetDomElement', () => { - it('only mounts the element when set up, before setting up the legacyPlatformService', () => { + it('only mounts the element when set up, before setting up the legacyPlatformService', async () => { const rootDomElement = document.createElement('div'); const core = createCoreSystem({ rootDomElement, @@ -332,32 +347,35 @@ describe('LegacyPlatform targetDomElement', () => { expect(targetDomElement).toHaveProperty('parentElement', null); // setting up the core system should mount the targetDomElement as a child of the rootDomElement - core.setup(); + await core.setup(); expect(targetDomElementParentInSetup!).toBe(rootDomElement); }); }); describe('Notifications targetDomElement', () => { - it('only mounts the element when set up, before setting up the notificationsService', () => { + it('only mounts the element when set up, before setting up the notificationsService', async () => { const rootDomElement = document.createElement('div'); const core = createCoreSystem({ rootDomElement, }); - let targetDomElementParentInSetup: HTMLElement; + const [[{ targetDomElement$ }]] = NotificationServiceConstructor.mock.calls; + let targetDomElementParentInSetup: HTMLElement | null; MockNotificationsService.setup.mockImplementation( (): any => { - targetDomElementParentInSetup = targetDomElement.parentElement; + (targetDomElement$ as Observable).pipe(take(1)).subscribe({ + next: targetDomElement => { + // The targetDomElement should already be a child once it's received by the NotificationsService + expect(targetDomElement.parentElement).not.toBeNull(); + targetDomElementParentInSetup = targetDomElement.parentElement; + }, + }); } ); - // targetDomElement should not have a parent element when the LegacyPlatformService is constructed - const [[{ targetDomElement }]] = NotificationServiceConstructor.mock.calls; - expect(targetDomElement).toHaveProperty('parentElement', null); - // setting up the core system should mount the targetDomElement as a child of the rootDomElement - core.setup(); + await core.setup(); expect(targetDomElementParentInSetup!).toBe(rootDomElement); }); }); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 11a00fd66c4c4b..0a8b93b6581569 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -19,6 +19,9 @@ import './core.css'; +import { Subject } from 'rxjs'; + +import { CoreSetup } from '.'; import { BasePathService } from './base_path'; import { ChromeService } from './chrome'; import { FatalErrorsService } from './fatal_errors'; @@ -27,6 +30,7 @@ import { I18nService } from './i18n'; import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformParams, LegacyPlatformService } from './legacy'; import { NotificationsService } from './notifications'; +import { PluginsService } from './plugins'; import { UiSettingsService } from './ui_settings'; interface Params { @@ -37,6 +41,10 @@ interface Params { useLegacyTestHarness?: LegacyPlatformParams['useLegacyTestHarness']; } +/** @internal */ +// tslint:disable-next-line no-empty-interface +export interface CoreContext {} + /** * The CoreSystem is the root of the new platform, and setups all parts * of Kibana in the UI, including the LegacyPlatform which is managed @@ -55,9 +63,10 @@ export class CoreSystem { private readonly basePath: BasePathService; private readonly chrome: ChromeService; private readonly i18n: I18nService; + private readonly plugins: PluginsService; private readonly rootDomElement: HTMLElement; - private readonly notificationsTargetDomElement: HTMLDivElement; + private readonly notificationsTargetDomElement$: Subject; private readonly legacyPlatformTargetDomElement: HTMLDivElement; constructor(params: Params) { @@ -85,15 +94,18 @@ export class CoreSystem { }, }); - this.notificationsTargetDomElement = document.createElement('div'); + this.notificationsTargetDomElement$ = new Subject(); this.notifications = new NotificationsService({ - targetDomElement: this.notificationsTargetDomElement, + targetDomElement$: this.notificationsTargetDomElement$.asObservable(), }); this.http = new HttpService(); this.basePath = new BasePathService(); this.uiSettings = new UiSettingsService(); this.chrome = new ChromeService({ browserSupportsCsp }); + const core: CoreContext = {}; + this.plugins = new PluginsService(core); + this.legacyPlatformTargetDomElement = document.createElement('div'); this.legacyPlatform = new LegacyPlatformService({ targetDomElement: this.legacyPlatformTargetDomElement, @@ -102,14 +114,8 @@ export class CoreSystem { }); } - public setup() { + public async setup() { try { - // ensure the rootDomElement is empty - this.rootDomElement.textContent = ''; - this.rootDomElement.classList.add('coreSystemRootDomElement'); - this.rootDomElement.appendChild(this.notificationsTargetDomElement); - this.rootDomElement.appendChild(this.legacyPlatformTargetDomElement); - const i18n = this.i18n.setup(); const notifications = this.notifications.setup({ i18n }); const injectedMetadata = this.injectedMetadata.setup(); @@ -127,16 +133,32 @@ export class CoreSystem { notifications, }); - this.legacyPlatform.setup({ + const core: CoreSetup = { + basePath, + chrome, + fatalErrors, + http, i18n, injectedMetadata, - fatalErrors, notifications, - http, - basePath, uiSettings, - chrome, - }); + }; + + await this.plugins.setup(core); + + // ensure the rootDomElement is empty + this.rootDomElement.textContent = ''; + this.rootDomElement.classList.add('coreSystemRootDomElement'); + + const notificationsTargetDomElement = document.createElement('div'); + this.rootDomElement.appendChild(notificationsTargetDomElement); + this.rootDomElement.appendChild(this.legacyPlatformTargetDomElement); + + // Only provide the DOM element to notifications once it's attached to the page. + // This prevents notifications from timing out before being displayed. + this.notificationsTargetDomElement$.next(notificationsTargetDomElement); + + this.legacyPlatform.setup(core); return { fatalErrors }; } catch (error) { @@ -146,6 +168,7 @@ export class CoreSystem { public stop() { this.legacyPlatform.stop(); + this.plugins.stop(); this.notifications.stop(); this.http.stop(); this.uiSettings.stop(); diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 57fea257206508..e29861988a5a13 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -26,7 +26,7 @@ import { InjectedMetadataSetup } from './injected_metadata'; import { NotificationsSetup } from './notifications'; import { UiSettingsSetup } from './ui_settings'; -export { CoreSystem } from './core_system'; +export { CoreContext, CoreSystem } from './core_system'; export interface CoreSetup { i18n: I18nSetup; @@ -38,3 +38,5 @@ export interface CoreSetup { uiSettings: UiSettingsSetup; chrome: ChromeSetup; } + +export { PluginInitializer, PluginInitializerContext, PluginSetupContext } from './plugins'; diff --git a/src/core/public/notifications/notifications_service.ts b/src/core/public/notifications/notifications_service.ts index 3e36ca0552c387..d9349a8cc540a6 100644 --- a/src/core/public/notifications/notifications_service.ts +++ b/src/core/public/notifications/notifications_service.ts @@ -17,11 +17,12 @@ * under the License. */ +import { Observable, Subject, Subscription } from 'rxjs'; import { I18nSetup } from '../i18n'; import { ToastsService } from './toasts'; interface Params { - targetDomElement: HTMLElement; + targetDomElement$: Observable; } interface Deps { @@ -31,27 +32,45 @@ interface Deps { export class NotificationsService { private readonly toasts: ToastsService; - private readonly toastsContainer: HTMLElement; + private readonly toastsContainer$: Subject; + private domElemSubscription?: Subscription; + private targetDomElement?: HTMLElement; constructor(private readonly params: Params) { - this.toastsContainer = document.createElement('div'); + this.toastsContainer$ = new Subject(); this.toasts = new ToastsService({ - targetDomElement: this.toastsContainer, + targetDomElement$: this.toastsContainer$.asObservable(), }); } public setup({ i18n }: Deps) { - this.params.targetDomElement.appendChild(this.toastsContainer); + this.domElemSubscription = this.params.targetDomElement$.subscribe({ + next: targetDomElement => { + this.cleanupTargetDomElement(); + this.targetDomElement = targetDomElement; - return { - toasts: this.toasts.setup({ i18n }), - }; + const toastsContainer = document.createElement('div'); + targetDomElement.appendChild(toastsContainer); + this.toastsContainer$.next(toastsContainer); + }, + }); + + return { toasts: this.toasts.setup({ i18n }) }; } public stop() { this.toasts.stop(); + this.cleanupTargetDomElement(); + + if (this.domElemSubscription) { + this.domElemSubscription.unsubscribe(); + } + } - this.params.targetDomElement.textContent = ''; + private cleanupTargetDomElement() { + if (this.targetDomElement) { + this.targetDomElement.textContent = ''; + } } } diff --git a/src/core/public/notifications/toasts/toasts_service.test.tsx b/src/core/public/notifications/toasts/toasts_service.test.tsx index d380ad5b77ea6c..dd985b77235b66 100644 --- a/src/core/public/notifications/toasts/toasts_service.test.tsx +++ b/src/core/public/notifications/toasts/toasts_service.test.tsx @@ -24,6 +24,7 @@ jest.mock('react-dom', () => ({ unmountComponentAtNode: mockReactDomUnmount, })); +import { of } from 'rxjs'; import { ToastsService } from './toasts_service'; import { ToastsSetup } from './toasts_start'; @@ -37,7 +38,7 @@ describe('#setup()', () => { it('renders the GlobalToastList into the targetDomElement param', async () => { const targetDomElement = document.createElement('div'); targetDomElement.setAttribute('test', 'target-dom-element'); - const toasts = new ToastsService({ targetDomElement }); + const toasts = new ToastsService({ targetDomElement$: of(targetDomElement) }); expect(mockReactDomRender).not.toHaveBeenCalled(); toasts.setup({ i18n: mockI18n }); @@ -46,7 +47,7 @@ describe('#setup()', () => { it('returns a ToastsSetup', () => { const toasts = new ToastsService({ - targetDomElement: document.createElement('div'), + targetDomElement$: of(document.createElement('div')), }); expect(toasts.setup({ i18n: mockI18n })).toBeInstanceOf(ToastsSetup); @@ -57,7 +58,7 @@ describe('#stop()', () => { it('unmounts the GlobalToastList from the targetDomElement', () => { const targetDomElement = document.createElement('div'); targetDomElement.setAttribute('test', 'target-dom-element'); - const toasts = new ToastsService({ targetDomElement }); + const toasts = new ToastsService({ targetDomElement$: of(targetDomElement) }); toasts.setup({ i18n: mockI18n }); @@ -69,7 +70,7 @@ describe('#stop()', () => { it('does not fail if setup() was never called', () => { const targetDomElement = document.createElement('div'); targetDomElement.setAttribute('test', 'target-dom-element'); - const toasts = new ToastsService({ targetDomElement }); + const toasts = new ToastsService({ targetDomElement$: of(targetDomElement) }); expect(() => { toasts.stop(); }).not.toThrowError(); @@ -77,9 +78,9 @@ describe('#stop()', () => { it('empties the content of the targetDomElement', () => { const targetDomElement = document.createElement('div'); - const toasts = new ToastsService({ targetDomElement }); + const toasts = new ToastsService({ targetDomElement$: of(targetDomElement) }); - targetDomElement.appendChild(document.createTextNode('foo bar')); + toasts.setup({ i18n: mockI18n }); toasts.stop(); expect(targetDomElement.childNodes).toHaveLength(0); }); diff --git a/src/core/public/notifications/toasts/toasts_service.tsx b/src/core/public/notifications/toasts/toasts_service.tsx index d4049adcefbad6..912d01689ac671 100644 --- a/src/core/public/notifications/toasts/toasts_service.tsx +++ b/src/core/public/notifications/toasts/toasts_service.tsx @@ -19,6 +19,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; +import { Observable, Subscription } from 'rxjs'; import { Toast } from '@elastic/eui'; import { I18nSetup } from '../../i18n'; @@ -26,7 +27,7 @@ import { GlobalToastList } from './global_toast_list'; import { ToastsSetup } from './toasts_start'; interface Params { - targetDomElement: HTMLElement; + targetDomElement$: Observable; } interface Deps { @@ -34,27 +35,46 @@ interface Deps { } export class ToastsService { + private domElemSubscription?: Subscription; + private targetDomElement?: HTMLElement; + constructor(private readonly params: Params) {} public setup({ i18n }: Deps) { const toasts = new ToastsSetup(); - render( - - toasts.remove(toast)} - toasts$={toasts.get$()} - /> - , - this.params.targetDomElement - ); + this.domElemSubscription = this.params.targetDomElement$.subscribe({ + next: targetDomElement => { + this.cleanupTargetDomElement(); + this.targetDomElement = targetDomElement; + + render( + + toasts.remove(toast)} + toasts$={toasts.get$()} + /> + , + targetDomElement + ); + }, + }); return toasts; } public stop() { - unmountComponentAtNode(this.params.targetDomElement); + this.cleanupTargetDomElement(); + + if (this.domElemSubscription) { + this.domElemSubscription.unsubscribe(); + } + } - this.params.targetDomElement.textContent = ''; + private cleanupTargetDomElement() { + if (this.targetDomElement) { + unmountComponentAtNode(this.targetDomElement); + this.targetDomElement.textContent = ''; + } } } diff --git a/src/core/public/plugins/index.ts b/src/core/public/plugins/index.ts new file mode 100644 index 00000000000000..ed1fe8fc660eec --- /dev/null +++ b/src/core/public/plugins/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './plugins_service'; +export { PluginInitializer } from './plugin'; +export { PluginInitializerContext, PluginSetupContext } from './plugin_context'; diff --git a/src/core/public/plugins/plugin.test.ts b/src/core/public/plugins/plugin.test.ts new file mode 100644 index 00000000000000..446ef22a4e9bf1 --- /dev/null +++ b/src/core/public/plugins/plugin.test.ts @@ -0,0 +1,109 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const mockPlugin = { + setup: jest.fn(), + stop: jest.fn(), +}; +const mockInitializer = jest.fn(() => mockPlugin); +const mockPluginLoader = jest.fn(() => Promise.resolve(mockInitializer)); + +jest.mock('./plugin_loader', () => ({ + loadPluginBundle: mockPluginLoader, +})); + +import { DiscoveredPlugin } from '../../server'; +import { Plugin } from './plugin'; + +function createManifest( + id: string, + { required = [], optional = [] }: { required?: string[]; optional?: string[]; ui?: boolean } = {} +) { + return { + id, + version: 'some-version', + configPath: ['path'], + requiredPlugins: required, + optionalPlugins: optional, + } as DiscoveredPlugin; +} + +let plugin: Plugin>; +const initializerContext = {}; +const addBasePath = (path: string) => path; + +beforeEach(() => { + mockPluginLoader.mockClear(); + mockPlugin.setup.mockClear(); + mockPlugin.stop.mockClear(); + plugin = new Plugin(createManifest('plugin-a'), initializerContext); +}); + +test('`load` calls loadPluginBundle', () => { + plugin.load(addBasePath); + expect(mockPluginLoader).toHaveBeenCalledWith(addBasePath, 'plugin-a'); +}); + +test('`setup` fails if load is not called first', async () => { + await expect(plugin.setup({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Plugin \\"plugin-a\\" can't be setup since its bundle isn't loaded."` + ); +}); + +test('`setup` fails if plugin.setup is not a function', async () => { + mockInitializer.mockReturnValueOnce({ stop: jest.fn() } as any); + await plugin.load(addBasePath); + await expect(plugin.setup({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Instance of plugin \\"plugin-a\\" does not define \\"setup\\" function."` + ); +}); + +test('`setup` calls initializer with initializer context', async () => { + await plugin.load(addBasePath); + await plugin.setup({} as any, {} as any); + expect(mockInitializer).toHaveBeenCalledWith(initializerContext); +}); + +test('`setup` calls plugin.setup with context and dependencies', async () => { + await plugin.load(addBasePath); + const context = { any: 'thing' } as any; + const deps = { otherDep: 'value' }; + await plugin.setup(context, deps); + expect(mockPlugin.setup).toHaveBeenCalledWith(context, deps); +}); + +test('`stop` fails if plugin is not setup up', async () => { + await expect(plugin.stop()).rejects.toThrowErrorMatchingInlineSnapshot( + `"Plugin \\"plugin-a\\" can't be stopped since it isn't set up."` + ); +}); + +test('`stop` calls plugin.stop', async () => { + await plugin.load(addBasePath); + await plugin.setup({} as any, {} as any); + await plugin.stop(); + expect(mockPlugin.stop).toHaveBeenCalled(); +}); + +test('`stop` does not fail if plugin.stop does not exist', async () => { + mockInitializer.mockReturnValueOnce({ setup: jest.fn() } as any); + await plugin.load(addBasePath); + await plugin.setup({} as any, {} as any); + await expect(plugin.stop()).resolves.not.toThrow(); +}); diff --git a/src/core/public/plugins/plugin.ts b/src/core/public/plugins/plugin.ts new file mode 100644 index 00000000000000..540ab65864088b --- /dev/null +++ b/src/core/public/plugins/plugin.ts @@ -0,0 +1,115 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DiscoveredPlugin, PluginName } from '../../server'; +import { PluginInitializerContext, PluginSetupContext } from './plugin_context'; +import { loadPluginBundle } from './plugin_loader'; + +/** + * The `plugin` export at the root of a plugin's `public` directory should conform + * to this interface. + */ +export type PluginInitializer> = ( + core: PluginInitializerContext +) => { + setup: (core: PluginSetupContext, dependencies: TDependencies) => TSetup | Promise; + stop?: () => void | Promise; +}; + +/** + * Lightweight wrapper around discovered plugin that is responsible for instantiating + * plugin and dispatching proper context and dependencies into plugin's lifecycle hooks. + * @internal + */ +export class Plugin< + TSetup = unknown, + TDependenciesSetup extends Record = Record +> { + public readonly name: DiscoveredPlugin['id']; + public readonly configPath: DiscoveredPlugin['configPath']; + public readonly requiredDependencies: DiscoveredPlugin['requiredPlugins']; + public readonly optionalDependencies: DiscoveredPlugin['optionalPlugins']; + private initializer?: PluginInitializer; + private instance?: ReturnType>; + + constructor( + public readonly discoveredPlugin: DiscoveredPlugin, + private readonly initializerContext: PluginInitializerContext + ) { + this.name = discoveredPlugin.id; + this.configPath = discoveredPlugin.configPath; + this.requiredDependencies = discoveredPlugin.requiredPlugins; + this.optionalDependencies = discoveredPlugin.optionalPlugins; + } + + /** + * Loads the plugin's bundle into the browser. Should be called in parallel with all plugins + * using `Promise.all`. Must be called before `setup`. + * @param addBasePath Function that adds the base path to a string for plugin bundle path. + */ + public async load(addBasePath: (path: string) => string) { + this.initializer = await loadPluginBundle(addBasePath, this.name); + } + + /** + * Instantiates plugin and calls `setup` function exposed by the plugin initializer. + * @param setupContext Context that consists of various core services tailored specifically + * for the `setup` lifecycle event. + * @param dependencies The dictionary where the key is the dependency name and the value + * is the contract returned by the dependency's `setup` function. + */ + public async setup(setupContext: PluginSetupContext, dependencies: TDependenciesSetup) { + this.instance = await this.createPluginInstance(); + + return await this.instance.setup(setupContext, dependencies); + } + + /** + * Calls optional `stop` function exposed by the plugin initializer. + */ + public async stop() { + if (this.instance === undefined) { + throw new Error( + `Plugin "${this.discoveredPlugin.id}" can't be stopped since it isn't set up.` + ); + } + + if (typeof this.instance.stop === 'function') { + await this.instance.stop(); + } + + this.instance = undefined; + } + + private async createPluginInstance() { + if (this.initializer === undefined) { + throw new Error(`Plugin "${this.name}" can't be setup since its bundle isn't loaded.`); + } + + const instance = this.initializer(this.initializerContext); + + if (typeof instance.setup !== 'function') { + throw new Error( + `Instance of plugin "${this.discoveredPlugin.id}" does not define "setup" function.` + ); + } + + return instance; + } +} diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts new file mode 100644 index 00000000000000..097be22ce3f809 --- /dev/null +++ b/src/core/public/plugins/plugin_context.ts @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DiscoveredPlugin } from '../../server'; +import { BasePathSetup } from '../base_path'; +import { ChromeSetup } from '../chrome'; +import { CoreContext } from '../core_system'; +import { FatalErrorsSetup } from '../fatal_errors'; +import { I18nSetup } from '../i18n'; +import { NotificationsSetup } from '../notifications'; +import { UiSettingsSetup } from '../ui_settings'; +import { Plugin } from './plugin'; +import { PluginsServiceSetupDeps } from './plugins_service'; + +// tslint:disable-next-line no-empty-interface +export interface PluginInitializerContext {} + +export interface PluginSetupContext { + basePath: BasePathSetup; + chrome: ChromeSetup; + fatalErrors: FatalErrorsSetup; + i18n: I18nSetup; + notifications: NotificationsSetup; + uiSettings: UiSettingsSetup; +} + +/** + * Provides a plugin-specific context passed to the plugin's construtor. This is currently + * empty but should provide static services in the future, such as config and logging. + * + * @param coreContext + * @param pluginManinfest + * @internal + */ +export function createPluginInitializerContext( + coreContext: CoreContext, + pluginManifest: DiscoveredPlugin +): PluginInitializerContext { + return {}; +} + +/** + * Provides a plugin-specific context passed to the plugin's `setup` lifecycle event. Currently + * this returns a shallow copy the service setup contracts, but in the future could provide + * plugin-scoped versions of the service. + * + * @param coreContext + * @param deps + * @param plugin + * @internal + */ +export function createPluginSetupContext( + coreContext: CoreContext, + deps: PluginsServiceSetupDeps, + plugin: Plugin +): PluginSetupContext { + return { ...deps }; +} diff --git a/src/core/public/plugins/plugin_loader.mock.ts b/src/core/public/plugins/plugin_loader.mock.ts new file mode 100644 index 00000000000000..5f55cbcc867448 --- /dev/null +++ b/src/core/public/plugins/plugin_loader.mock.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginName } from 'src/core/server'; +import { LoadPluginBundle, UnknownPluginInitializer } from './plugin_loader'; + +/** + * @param initializerProvider A function provided by the test to resolve initializers. + */ +const createLoadPluginBundleMock = ( + initializerProvider: (name: PluginName) => UnknownPluginInitializer +): jest.Mock, Parameters> => + jest.fn((addBasePath, pluginName) => { + return Promise.resolve(initializerProvider(pluginName)) as any; + }); + +export const loadPluginBundleMock = { create: createLoadPluginBundleMock }; diff --git a/src/core/public/plugins/plugin_loader.test.ts b/src/core/public/plugins/plugin_loader.test.ts new file mode 100644 index 00000000000000..5ec63a80ec25a2 --- /dev/null +++ b/src/core/public/plugins/plugin_loader.test.ts @@ -0,0 +1,117 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { loadPluginBundle } from './plugin_loader'; + +let createdScriptTags = [] as any[]; +let appendChildSpy: jest.Mock; +let createElementSpy: jest.Mock; + +beforeEach(() => { + // Mock document.createElement to return fake tags we can use to inspect what + // loadPluginBundles does. + createdScriptTags = []; + createElementSpy = jest.spyOn(document, 'createElement').mockImplementation(() => { + const scriptTag = { setAttribute: jest.fn() } as any; + createdScriptTags.push(scriptTag); + return scriptTag; + }); + + // Mock document.body.appendChild to avoid errors about appending objects that aren't `Node`'s + // and so we can verify that the script tags were added to the page. + appendChildSpy = jest.spyOn(document.body, 'appendChild').mockReturnValue({} as any); + + // Mock global fields needed for loading modules. + window.__kbnNonce__ = 'asdf'; + window.__kbnBundles__ = {}; +}); + +afterEach(() => { + appendChildSpy.mockRestore(); + createElementSpy.mockRestore(); + delete window.__kbnNonce__; + delete window.__kbnBundles__; +}); + +const addBasePath = (path: string) => path; + +test('`loadPluginBundles` creates a script tag and loads initializer', async () => { + const loadPromise = loadPluginBundle(addBasePath, 'plugin-a'); + + // Verify it sets up the script tag correctly and adds it to document.body + expect(createdScriptTags).toHaveLength(1); + const fakeScriptTag = createdScriptTags[0]; + expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith( + 'src', + '/bundles/plugin/plugin-a.bundle.js' + ); + expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith('id', 'kbn-plugin-plugin-a'); + expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith('nonce', 'asdf'); + expect(fakeScriptTag.onload).toBeInstanceOf(Function); + expect(fakeScriptTag.onerror).toBeInstanceOf(Function); + expect(appendChildSpy).toHaveBeenCalledWith(fakeScriptTag); + + // Setup a fake initializer as if a plugin bundle had actually been loaded. + const fakeInitializer = jest.fn(); + window.__kbnBundles__['plugin/plugin-a'] = fakeInitializer; + // Call the onload callback + fakeScriptTag.onload(); + await expect(loadPromise).resolves.toEqual(fakeInitializer); +}); + +test('`loadPluginBundles` includes the basePath', async () => { + loadPluginBundle((path: string) => `/mybasepath${path}`, 'plugin-a'); + + // Verify it sets up the script tag correctly and adds it to document.body + expect(createdScriptTags).toHaveLength(1); + const fakeScriptTag = createdScriptTags[0]; + expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith( + 'src', + '/mybasepath/bundles/plugin/plugin-a.bundle.js' + ); +}); + +test('`loadPluginBundles` rejects if any script fails', async () => { + const loadPromise = loadPluginBundle(addBasePath, 'plugin-a'); + + const fakeScriptTag1 = createdScriptTags[0]; + + // Setup a fake initializer as if a plugin bundle had actually been loaded. + const fakeInitializer1 = jest.fn(); + window.__kbnBundles__['plugin/plugin-a'] = fakeInitializer1; + // Call the error on the second script + fakeScriptTag1.onerror(new Error('Whoa there!')); + + await expect(loadPromise).rejects.toThrowError('Whoa there!'); +}); + +test('`loadPluginBundles` rejects if bundle does attach an initializer to window.__kbnBundles__', async () => { + const loadPromise = loadPluginBundle(addBasePath, 'plugin-a'); + + const fakeScriptTag1 = createdScriptTags[0]; + + // Setup a fake initializer as if a plugin bundle had actually been loaded. + window.__kbnBundles__['plugin/plugin-a'] = undefined; + // Call the onload callback + fakeScriptTag1.onload(); + + await expect(loadPromise).rejects.toThrowErrorMatchingInlineSnapshot( + `"Definition of plugin \\"plugin-a\\" should be a function (/bundles/plugin/plugin-a.bundle.js)."` + ); +}); diff --git a/src/core/public/plugins/plugin_loader.ts b/src/core/public/plugins/plugin_loader.ts new file mode 100644 index 00000000000000..299bd8827e1b2f --- /dev/null +++ b/src/core/public/plugins/plugin_loader.ts @@ -0,0 +1,98 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginName } from '../../server'; +import { PluginInitializer } from './plugin'; + +/** + * Unknown variant for internal use only for when plugins are not known. + * @internal + */ +export type UnknownPluginInitializer = PluginInitializer>; + +/** + * Extend window with types for loading bundles + * @internal + */ +declare global { + interface Window { + __kbnNonce__: string; + __kbnBundles__: { + [pluginBundleName: string]: UnknownPluginInitializer | undefined; + }; + } +} + +/** + * Loads the bundle for a plugin onto the page and returns their PluginInitializer. This should + * be called for all plugins (once per plugin) in parallel using Promise.all. + * + * If this is slowing down browser load time, there are some ways we could make this faster: + * - Add these bundles in the generated bootstrap.js file so they're loaded immediately + * - Concatenate all the bundles files on the backend and serve them in single request. + * - Use HTTP/2 to load these bundles without having to open new connections for each. + * + * This may not be much of an issue since these should be cached by the browser after the first + * page load. + * + * @param basePath + * @param plugins + * @internal + */ +export const loadPluginBundle: LoadPluginBundle = < + TSetup, + TDependencies extends Record +>( + addBasePath: (path: string) => string, + pluginName: PluginName +) => + new Promise>((resolve, reject) => { + const script = document.createElement('script'); + + // Assumes that all plugin bundles get put into the bundles/plugins subdirectory + const bundlePath = addBasePath(`/bundles/plugin/${pluginName}.bundle.js`); + script.setAttribute('src', bundlePath); + script.setAttribute('id', `kbn-plugin-${pluginName}`); + script.setAttribute('async', ''); + + // Add kbnNonce for CSP + script.setAttribute('nonce', window.__kbnNonce__); + + // Wire up resolve and reject + script.onload = () => { + const initializer = window.__kbnBundles__[`plugin/${pluginName}`]; + + if (!initializer || typeof initializer !== 'function') { + reject( + new Error(`Definition of plugin "${pluginName}" should be a function (${bundlePath}).`) + ); + } else { + resolve(initializer as PluginInitializer); + } + }; + script.onerror = reject; + + // Add the script tag to the end of the body to start downloading + document.body.appendChild(script); + }); + +export type LoadPluginBundle = >( + addBasePath: (path: string) => string, + pluginName: PluginName +) => Promise>; diff --git a/src/core/public/plugins/plugins_service.mock.ts b/src/core/public/plugins/plugins_service.mock.ts new file mode 100644 index 00000000000000..d4c6ffb12aaefa --- /dev/null +++ b/src/core/public/plugins/plugins_service.mock.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginsService, PluginsServiceSetup } from './plugins_service'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked> = { + pluginSetups: new Map(), + }; + // we have to suppress type errors until decide how to mock es6 class + return (setupContract as unknown) as PluginsServiceSetup; +}; + +type PluginsServiceContract = PublicMethodsOf; +const createMock = () => { + const mocked: jest.Mocked = { + setup: jest.fn(), + stop: jest.fn(), + }; + + mocked.setup.mockResolvedValue(createSetupContractMock()); + return mocked; +}; + +export const pluginsServiceMock = { + create: createMock, + createSetupContract: createSetupContractMock, +}; diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts new file mode 100644 index 00000000000000..70590f2b7edc00 --- /dev/null +++ b/src/core/public/plugins/plugins_service.test.ts @@ -0,0 +1,193 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginName } from 'src/core/server'; +import { CoreContext } from '../core_system'; +import { Plugin } from './plugin'; +import { loadPluginBundleMock } from './plugin_loader.mock'; + +type MockedPluginInitializer = jest.Mock>, any>; + +let mockPluginInitializers: Map; + +const mockLoadPluginBundle = loadPluginBundleMock.create( + (pluginName: string) => mockPluginInitializers.get(pluginName)! +); +jest.mock('./plugin_loader', () => ({ + loadPluginBundle: mockLoadPluginBundle, +})); + +import { PluginsService } from './plugins_service'; + +const mockCoreContext: CoreContext = {}; +let mockDeps: any; + +beforeEach(() => { + mockDeps = { + injectedMetadata: { + getPlugins: jest.fn(() => [ + { id: 'pluginA', plugin: createManifest('pluginA') }, + { id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) }, + { + id: 'pluginC', + plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }), + }, + ]), + }, + basePath: { + addToPath(path: string) { + return path; + }, + }, + } as any; + + // Reset these for each test. + mockPluginInitializers = new Map(([ + [ + 'pluginA', + jest.fn(() => ({ + setup: jest.fn(() => ({ exportedValue: 1 })), + stop: jest.fn(), + })), + ], + [ + 'pluginB', + jest.fn(() => ({ + setup: jest.fn((core, deps: any) => ({ + pluginAPlusB: deps.pluginA.exportedValue + 1, + })), + stop: jest.fn(), + })), + ], + [ + 'pluginC', + jest.fn(() => ({ + setup: jest.fn(), + stop: jest.fn(), + })), + ], + ] as unknown) as [[PluginName, any]]); +}); + +afterEach(() => { + mockLoadPluginBundle.mockClear(); +}); + +function createManifest( + id: string, + { required = [], optional = [] }: { required?: string[]; optional?: string[]; ui?: boolean } = {} +) { + return { + id, + version: 'some-version', + configPath: ['path'], + requiredPlugins: required, + optionalPlugins: optional, + }; +} + +test('`PluginsService.setup` fails if any bundle cannot be loaded', async () => { + mockLoadPluginBundle.mockRejectedValueOnce(new Error('Could not load bundle')); + + const pluginsService = new PluginsService(mockCoreContext); + await expect(pluginsService.setup(mockDeps)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not load bundle"` + ); +}); + +test('`PluginsService.setup` fails if any plugin instance does not have a setup function', async () => { + mockPluginInitializers.set('pluginA', (() => ({})) as any); + const pluginsService = new PluginsService(mockCoreContext); + await expect(pluginsService.setup(mockDeps)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Instance of plugin \\"pluginA\\" does not define \\"setup\\" function."` + ); +}); + +test('`PluginsService.setup` calls loadPluginBundles with basePath and plugins', async () => { + const pluginsService = new PluginsService(mockCoreContext); + await pluginsService.setup(mockDeps); + + expect(mockLoadPluginBundle).toHaveBeenCalledTimes(3); + expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockDeps.basePath.addToPath, 'pluginA'); + expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockDeps.basePath.addToPath, 'pluginB'); + expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockDeps.basePath.addToPath, 'pluginC'); +}); + +test('`PluginsService.setup` initalizes plugins with CoreContext', async () => { + const pluginsService = new PluginsService(mockCoreContext); + await pluginsService.setup(mockDeps); + + expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(mockCoreContext); + expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(mockCoreContext); + expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(mockCoreContext); +}); + +test('`PluginsService.setup` exposes dependent setup contracts to plugins', async () => { + const pluginsService = new PluginsService(mockCoreContext); + await pluginsService.setup(mockDeps); + + const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; + const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value; + const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value; + + expect(pluginAInstance.setup).toHaveBeenCalledWith(mockDeps, {}); + expect(pluginBInstance.setup).toHaveBeenCalledWith(mockDeps, { pluginA: { exportedValue: 1 } }); + // Does not supply value for `nonexist` optional dep + expect(pluginCInstance.setup).toHaveBeenCalledWith(mockDeps, { pluginA: { exportedValue: 1 } }); +}); + +test('`PluginsService.setup` does not set missing dependent setup contracts', async () => { + mockDeps.injectedMetadata.getPlugins.mockReturnValue([ + { id: 'pluginD', plugin: createManifest('pluginD', { required: ['missing'] }) }, + ]); + mockPluginInitializers.set('pluginD', jest.fn(() => ({ setup: jest.fn() })) as any); + + const pluginsService = new PluginsService(mockCoreContext); + await pluginsService.setup(mockDeps); + + // If a dependency is missing it should not be in the deps at all, not even as undefined. + const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value; + expect(pluginDInstance.setup).toHaveBeenCalledWith(mockDeps, {}); + const pluginDDeps = pluginDInstance.setup.mock.calls[0][1]; + expect(pluginDDeps).not.toHaveProperty('missing'); +}); + +test('`PluginsService.setup` returns plugin setup contracts', async () => { + const pluginsService = new PluginsService(mockCoreContext); + const { contracts } = await pluginsService.setup(mockDeps); + + // Verify that plugin contracts were available + expect((contracts.get('pluginA')! as any).exportedValue).toEqual(1); + expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(2); +}); + +test('`PluginService.stop` calls the stop function on each plugin', async () => { + const pluginsService = new PluginsService(mockCoreContext); + await pluginsService.setup(mockDeps); + + const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; + const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value; + const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value; + + await pluginsService.stop(); + + expect(pluginAInstance.stop).toHaveBeenCalled(); + expect(pluginBInstance.stop).toHaveBeenCalled(); + expect(pluginCInstance.stop).toHaveBeenCalled(); +}); diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts new file mode 100644 index 00000000000000..62571c7cf6fe6e --- /dev/null +++ b/src/core/public/plugins/plugins_service.ts @@ -0,0 +1,101 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CoreSetup } from '..'; +import { CoreService } from '../../types'; +import { CoreContext } from '../core_system'; +import { Plugin } from './plugin'; +import { createPluginInitializerContext, createPluginSetupContext } from './plugin_context'; + +/** @internal */ +export type PluginsServiceSetupDeps = CoreSetup; + +/** @internal */ +export interface PluginsServiceSetup { + contracts: Map; +} + +/** + * Service responsible for loading plugin bundles, initializing plugins, and managing the lifecycle + * of all plugins. + * + * @internal + */ +// tslint:disable max-classes-per-file +export class PluginsService implements CoreService { + private readonly plugins: Map>> = new Map(); + + constructor(private readonly coreContext: CoreContext) {} + + public async setup(deps: PluginsServiceSetupDeps) { + // Construct plugin wrappers + deps.injectedMetadata + .getPlugins() + .forEach(({ id, plugin }) => + this.plugins.set(id, new Plugin(plugin, createPluginInitializerContext(deps, plugin))) + ); + + // Load plugin bundles + await this.loadPluginBundles(deps.basePath.addToPath); + + // Setup each plugin with correct dependencies + const contracts = new Map(); + for (const [pluginName, plugin] of this.plugins.entries()) { + const dependencies = new Set([ + ...plugin.requiredDependencies, + ...plugin.optionalDependencies.filter(optPlugin => this.plugins.get(optPlugin)), + ]); + + const dependencyContracts = [...dependencies.keys()].reduce( + (depContracts, dependency) => { + // Only set if present. Could be absent if plugin does not have client-side code or is a + // missing optional dependency. + if (contracts.get(dependency)) { + depContracts[dependency] = contracts.get(dependency); + } + return depContracts; + }, + {} as { [dep: string]: unknown } + ); + + contracts.set( + pluginName, + await plugin.setup( + createPluginSetupContext(this.coreContext, deps, plugin), + dependencyContracts + ) + ); + } + + // Expose setup contracts + return { contracts }; + } + + public async stop() { + // Stop plugins in reverse dependency order. + for (const plugin of [...this.plugins.values()].reverse()) { + await plugin.stop(); + } + } + + private loadPluginBundles(addBasePath: (path: string) => string) { + // Load all bundles in parallel + return Promise.all([...this.plugins.values()].map(plugin => plugin.load(addBasePath))); + } +} diff --git a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js index 99238fcd1c9ccc..9a981e72e9c6e0 100644 --- a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js +++ b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js @@ -58,6 +58,7 @@ new CoreSystem({ csp: { warnLegacyBrowsers: false, }, + uiPlugins: [], vars: { kbnIndex: '.kibana', esShardTimeout: 1500, diff --git a/src/legacy/ui/ui_bundles/app_entry_template.js b/src/legacy/ui/ui_bundles/app_entry_template.js index 2d92dc30556fb9..16a41cedcc7439 100644 --- a/src/legacy/ui/ui_bundles/app_entry_template.js +++ b/src/legacy/ui/ui_bundles/app_entry_template.js @@ -56,10 +56,12 @@ i18n.load(injectedMetadata.i18n.translationsUrl) } }); - const coreStart = coreSystem.setup(); - - if (i18nError) { - coreStart.fatalErrors.add(i18nError); - } + coreSystem + .setup() + .then((coreSetup) => { + if (i18nError) { + coreSetup.fatalErrors.add(i18nError); + } + }); }); `;