diff --git a/lib/static/components/extension-point.jsx b/lib/static/components/extension-point.jsx deleted file mode 100644 index 1a5410c81..000000000 --- a/lib/static/components/extension-point.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, {Component, Fragment} from 'react'; -import PropTypes from 'prop-types'; -import ErrorBoundary from './error-boundary'; -import * as plugins from '../modules/plugins'; - -export default class ExtensionPoint extends Component { - static propTypes = { - name: PropTypes.string.isRequired, - children: PropTypes.oneOfType([PropTypes.element, PropTypes.string, PropTypes.array]) - }; - - render() { - const loadedPluginConfigs = plugins.getLoadedConfigs(); - - if (loadedPluginConfigs.length) { - const {name: pointName, children: reportComponent, ...componentProps} = this.props; - const pluginComponents = getExtensionPointComponents(loadedPluginConfigs, pointName); - return getComponentsComposition(pluginComponents, reportComponent, componentProps); - } - - return this.props.children; - } -} - -function getComponentsComposition(pluginComponents, reportComponent, componentProps) { - let currentComponent = reportComponent; - - for (const {PluginComponent, position, config} of pluginComponents) { - currentComponent = composeComponents(PluginComponent, componentProps, currentComponent, position, config); - } - - return currentComponent; -} - -function composeComponents(PluginComponent, pluginProps, currentComponent, position, config) { - switch (position) { - case 'wrap': - return - - {currentComponent} - - ; - case 'before': - return - - - - {currentComponent} - ; - case 'after': - return - {currentComponent} - - - - ; - default: - console.error(`${getComponentSpec(config)} unexpected position "${position}" specified.`); - return currentComponent; - } -} - -function getExtensionPointComponents(loadedPluginConfigs, pointName) { - return loadedPluginConfigs - .map(config => { - try { - const PluginComponent = plugins.get(config.name, config.component); - return { - PluginComponent, - name, - point: getComponentPoint(PluginComponent, config), - position: getComponentPosition(PluginComponent, config), - config - }; - } catch (err) { - console.error(err); - return {}; - } - }) - .filter(({point, position}) => { - return point && position && point === pointName; - }); -} - -function getComponentPoint(component, config) { - return getComponentConfigField(component, config, 'point'); -} - -function getComponentPosition(component, config) { - return getComponentConfigField(component, config, 'position'); -} - -function getComponentConfigField(component, config, field) { - if (component[field] && config[field] && component[field] !== config[field]) { - console.error(`${getComponentSpec(config)} "${field}" field does not match the one from the config: "${Component[field]}" vs "${config[field]}".`); - return null; - } else if (!component[field] && !config[field]) { - console.error(`${getComponentSpec(config)} "${field}" field is not set.`); - return null; - } - - return component[field] || config[field]; -} - -function getComponentSpec(config) { - return `Component "${config.component}" of "${config.name}" plugin`; -} diff --git a/lib/static/components/extension-point.tsx b/lib/static/components/extension-point.tsx new file mode 100644 index 000000000..684bee57d --- /dev/null +++ b/lib/static/components/extension-point.tsx @@ -0,0 +1,136 @@ +import React, {Component, FC, ReactNode} from 'react'; +import * as plugins from '../modules/plugins'; +import {PLUGIN_COMPONENT_POSITIONS, PluginComponentPosition, PluginDescription} from '@/types'; +import ErrorBoundary from './error-boundary'; + +interface ExtensionPointProps { + name: string; + children?: React.ReactNode; +} + +interface ExtensionPointComponent { + PluginComponent: FC; + name: string; + point: string; + position: PluginComponentPosition; + config: PluginDescription; +} + +type ExtensionPointComponentUnchecked = + Omit + & Partial> + +export default class ExtensionPoint extends Component { + render(): ReactNode { + const loadedPluginConfigs = plugins.getLoadedConfigs(); + + if (loadedPluginConfigs.length) { + const {name: pointName, children: reportComponent, ...componentProps} = this.props; + const pluginComponents = getExtensionPointComponents(loadedPluginConfigs, pointName); + return getComponentsComposition(pluginComponents, reportComponent, componentProps); + } + + return this.props.children; + } +} + +function getComponentsComposition(pluginComponents: ExtensionPointComponent[], reportComponent: ReactNode, componentProps: any): ReactNode { + let currentComponent = reportComponent; + + for (const {PluginComponent, position, config} of pluginComponents) { + currentComponent = composeComponents(PluginComponent, componentProps, currentComponent, position, config); + } + + return currentComponent; +} + +function composeComponents(PluginComponent: FC, pluginProps: any, currentComponent: ReactNode, position: PluginComponentPosition, config: PluginDescription): ReactNode { + switch (position) { + case 'wrap': + return + + {currentComponent} + + ; + case 'before': + return <> + + + + {currentComponent} + ; + case 'after': + return <> + {currentComponent} + + + + ; + default: + console.error(`${getComponentSpec(config)} unexpected position "${position}" specified.`); + return currentComponent; + } +} + +function getExtensionPointComponents(loadedPluginConfigs: PluginDescription[], pointName: string): ExtensionPointComponent[] { + return loadedPluginConfigs + .map(pluginDescription => { + try { + const PluginComponent = plugins.getPluginField(pluginDescription.name, pluginDescription.component); + + return { + PluginComponent, + name: pluginDescription.name, + point: getComponentPoint(PluginComponent, pluginDescription), + position: getComponentPosition(PluginComponent, pluginDescription), + config: pluginDescription + }; + } catch (err) { + console.error(err); + return {} as ExtensionPointComponentUnchecked; + } + }) + .filter((component: ExtensionPointComponentUnchecked): component is ExtensionPointComponent => { + return Boolean(component.point && component.position && component.point === pointName); + }); +} + +function getComponentPoint(component: FC, config: PluginDescription): string | undefined { + const result = getComponentConfigField(component, config, 'point'); + + if (typeof result !== 'string') { + return; + } + + return result as string; +} + +function getComponentPosition(component: FC, config: PluginDescription): PluginComponentPosition | undefined { + const result = getComponentConfigField(component, config, 'position'); + + if (typeof result !== 'string') { + return; + } + + if (!PLUGIN_COMPONENT_POSITIONS.includes(result as PluginComponentPosition)) { + return; + } + + return result as PluginComponentPosition; +} + +function getComponentConfigField(component: any, config: any, field: string): unknown | null { + if (component[field] && config[field] && component[field] !== config[field]) { + console.error(`${getComponentSpec(config)} "${field}" field does not match the one from the config: "${component[field]}" vs "${config[field]}".`); + return null; + } else if (!component[field] && !config[field]) { + console.error(`${getComponentSpec(config)} "${field}" field is not set.`); + return null; + } + + return component[field] || config[field]; +} + +function getComponentSpec(pluginDescription: PluginDescription): string { + return `Component "${pluginDescription.component}" of "${pluginDescription.name}" plugin`; +} diff --git a/lib/static/modules/load-plugin.js b/lib/static/modules/load-plugin.ts similarity index 51% rename from lib/static/modules/load-plugin.js rename to lib/static/modules/load-plugin.ts index dd4592f19..88a0ac344 100644 --- a/lib/static/modules/load-plugin.js +++ b/lib/static/modules/load-plugin.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {Component, FC} from 'react'; import * as Redux from 'redux'; import * as ReactRedux from 'react-redux'; import _ from 'lodash'; @@ -13,6 +13,7 @@ import axios from 'axios'; import * as selectors from './selectors'; import actionNames from './action-names'; import Details from '../components/details'; +import {PluginConfig} from '@/types'; const whitelistedDeps = { 'react': React, @@ -32,6 +33,34 @@ const whitelistedDeps = { } }; +type WhitelistedDeps = typeof whitelistedDeps; +type WhitelistedDepName = keyof WhitelistedDeps; +type WhitelistedDep = typeof whitelistedDeps[WhitelistedDepName]; + +// Branded string +type ScriptText = string & {__script_text__: never}; + +export type InstalledPlugin = { + name?: string; + component?: FC | Component; + [key: string]: unknown; +} + +interface PluginOptions { + pluginName: string; + pluginConfig: PluginConfig; + actions: typeof import('./actions'); + actionNames: typeof actionNames; + selectors: typeof selectors; +} + +type PluginFunction = (args: [...WhitelistedDep[], PluginOptions]) => InstalledPlugin + +type ModuleWithDefaultFunction = {default: PluginFunction}; + +type CompiledPluginWithDeps = [...WhitelistedDepName[], PluginFunction]; +type CompiledPlugin = InstalledPlugin | ModuleWithDefaultFunction | PluginFunction | CompiledPluginWithDeps + // It's expected that in the plugin code there is a // __testplane_html_reporter_register_plugin__ call, // with actual plugin passed. @@ -49,16 +78,13 @@ const whitelistedDeps = { // - an array with the string list of required dependencies and a function as the last item. // The function will be called with the dependencies as arguments plus `options` arg. -const loadingPlugins = {}; -const pendingPlugins = {}; - -const getPluginScriptPath = pluginName => `plugins/${encodeURIComponent(pluginName)}/plugin.js`; - -export function preloadPlugin(pluginName) { +const loadingPlugins: Record | undefined> = {}; +const pendingPlugins: Record | undefined> = {}; +export function preloadPlugin(pluginName: string): void { loadingPlugins[pluginName] = loadingPlugins[pluginName] || getScriptText(pluginName); } -export async function loadPlugin(pluginName, pluginConfig) { +export async function loadPlugin(pluginName: string, pluginConfig?: PluginConfig): Promise { if (pendingPlugins[pluginName]) { return pendingPlugins[pluginName]; } @@ -66,55 +92,73 @@ export async function loadPlugin(pluginName, pluginConfig) { const scriptTextPromise = loadingPlugins[pluginName] || getScriptText(pluginName); return pendingPlugins[pluginName] = scriptTextPromise - .then(executePluginCode) + .then(compilePlugin) .then(plugin => initPlugin(plugin, pluginName, pluginConfig)) - .then(null, err => { + .catch(err => { console.error(`Plugin "${pluginName}" failed to load.`, err); - return null; + return undefined; }); } -async function initPlugin(plugin, pluginName, pluginConfig) { +const hasDefault = (plugin: CompiledPlugin): plugin is ModuleWithDefaultFunction => + _.isObject(plugin) && !_.isArray(plugin) && !_.isFunction(plugin) && (_.isFunction(plugin.default) || _.isArray(plugin.default)); + +const getDeps = (pluginWithDeps: CompiledPluginWithDeps): WhitelistedDepName[] => pluginWithDeps.slice(0, -1) as WhitelistedDepName[]; +const getPluginFn = (pluginWithDeps: CompiledPluginWithDeps): PluginFunction => _.last(pluginWithDeps) as PluginFunction; + +async function initPlugin(plugin: CompiledPlugin, pluginName: string, pluginConfig?: PluginConfig): Promise { try { if (!_.isObject(plugin)) { - return null; + return undefined; } - plugin = plugin.default || plugin; + plugin = hasDefault(plugin) ? plugin.default : plugin; if (typeof plugin === 'function') { plugin = [plugin]; } if (Array.isArray(plugin)) { - const deps = plugin.slice(0, -1); - plugin = _.last(plugin); + const deps = getDeps(plugin); + + const pluginFn = getPluginFn(plugin); + const depArgs = deps.map(dep => whitelistedDeps[dep]); + // cyclic dep, resolve it dynamically const actions = await import('./actions'); - return plugin(...depArgs, {pluginName, pluginConfig, actions, actionNames, selectors}); + + // @ts-expect-error Unfortunately, for historical reasons + // the order of arguments and their types are not amenable to normal typing, so we will have to ignore the error here. + return pluginFn(...depArgs, {pluginName, pluginConfig, actions, actionNames, selectors}); } return plugin; } catch (err) { console.error(`Error on "${pluginName}" plugin initialization:`, err); - return null; + return undefined; } } // Actual plugin is passed to __testplane_html_reporter_register_plugin__ somewhere in the // plugin code -function executePluginCode(code) { - const getRegisterFn = tool => `function __${tool}_html_reporter_register_plugin__(p) {return p;};`; - +function compilePlugin(code: ScriptText): CompiledPlugin { const exec = new Function(`${getRegisterFn('testplane')} ${getRegisterFn('hermione')} return ${code};`); return exec(); } -async function getScriptText(pluginName) { +async function getScriptText(pluginName: string): Promise { const scriptUrl = getPluginScriptPath(pluginName); const {data} = await axios.get(scriptUrl); return data; } + +function getRegisterFn(tool: string): string { + return `function __${tool}_html_reporter_register_plugin__(p) {return p;};`; +} + +function getPluginScriptPath(pluginName: string): string { + return `plugins/${encodeURIComponent(pluginName)}/plugin.js`; +} diff --git a/lib/static/modules/plugins.js b/lib/static/modules/plugins.js deleted file mode 100644 index 70b30e08f..000000000 --- a/lib/static/modules/plugins.js +++ /dev/null @@ -1,58 +0,0 @@ -import {loadPlugin, preloadPlugin} from './load-plugin'; - -const plugins = Object.create(null); -const loadedPluginConfigs = []; - -export function preloadAll(config) { - if (!config || !config.pluginsEnabled || !Array.isArray(config.plugins)) { - return; - } - - config.plugins.forEach(plugin => preloadPlugin(plugin.name)); -} - -export async function loadAll(config) { - // if plugins are disabled, act like there are no plugins defined - if (!config || !config.pluginsEnabled || !Array.isArray(config.plugins)) { - return; - } - - const pluginConfigs = await Promise.all(config.plugins.map(async pluginConfig => { - const plugin = await loadPlugin(pluginConfig.name, pluginConfig.config); - if (plugin) { - plugins[pluginConfig.name] = plugin; - return pluginConfig; - } - })); - - loadedPluginConfigs.push(...pluginConfigs.filter(Boolean)); -} - -export function forEach(callback) { - const visited = new Set(); - loadedPluginConfigs.forEach(({name}) => { - if (!visited.has(name)) { - visited.add(name); - try { - callback(plugins[name], name); - } catch (err) { - console.error(`Error on "${name}" plugin iteration:`, err); - } - } - }); -} - -export function get(name, field) { - const plugin = plugins[name]; - if (!plugin) { - throw new Error(`Plugin "${name}" is not loaded.`); - } - if (!plugin[field]) { - throw new Error(`"${field}" is not defined on plugin "${name}".`); - } - return plugins[name][field]; -} - -export function getLoadedConfigs() { - return loadedPluginConfigs; -} diff --git a/lib/static/modules/plugins.ts b/lib/static/modules/plugins.ts new file mode 100644 index 000000000..0e454f8c9 --- /dev/null +++ b/lib/static/modules/plugins.ts @@ -0,0 +1,81 @@ +import {ConfigForStaticFile} from '@/server-utils'; +import {InstalledPlugin, loadPlugin, preloadPlugin} from './load-plugin'; +import {PluginDescription} from '@/types'; + +export type ExtensionPointComponentPosition = 'wrap' | 'before' | 'after' + +const plugins: Record = {}; +const loadedPluginConfigs: PluginDescription[] = []; + +export function preloadAll(config?: ConfigForStaticFile): void { + if (!config || !config.pluginsEnabled || !Array.isArray(config.plugins)) { + return; + } + + config.plugins.forEach(plugin => preloadPlugin(plugin.name)); +} + +export async function loadAll(config?: ConfigForStaticFile): Promise { + if (!config || !Array.isArray(config.plugins)) { + return; + } + + // if plugins are disabled, act like there are no plugins defined + if (!config.pluginsEnabled) { + if (config.plugins.length > 0) { + console.warn(`HTML Reporter plugins are disabled, but there are ${config.plugins.length} plugins in the config. Please, check your testplane.config.ts file and set pluginsEnabled to true.`); + } + + return; + } + + const pluginsSetupInfo = await Promise.all(config.plugins.map(async pluginDescription => { + const plugin = await loadPlugin(pluginDescription.name, pluginDescription.config); + + if (plugin) { + plugins[pluginDescription.name] = plugin; + return pluginDescription; + } + + return undefined; + })); + + pluginsSetupInfo.map((setupInfo) => { + if (!setupInfo) { + return; + } + + loadedPluginConfigs.push(setupInfo); + }); +} + +type ForEachPluginCallback = (plugin: InstalledPlugin, name: string) => void; + +export function forEachPlugin(callback: ForEachPluginCallback): void { + const visited = new Set(); + loadedPluginConfigs.forEach(({name}) => { + if (!visited.has(name)) { + visited.add(name); + try { + callback(plugins[name], name); + } catch (err) { + console.error(`Error on "${name}" plugin iteration:`, err); + } + } + }); +} + +export function getPluginField(name: string, field: string): T { + const plugin = plugins[name]; + if (!plugin) { + throw new Error(`Plugin "${name}" is not loaded.`); + } + if (!plugin[field]) { + throw new Error(`"${field}" is not defined on plugin "${name}".`); + } + return plugins[name][field] as T; +} + +export function getLoadedConfigs(): PluginDescription[] { + return loadedPluginConfigs; +} diff --git a/lib/static/modules/reducers/plugins.js b/lib/static/modules/reducers/plugins.js index 61effa9de..de8327cd1 100644 --- a/lib/static/modules/reducers/plugins.js +++ b/lib/static/modules/reducers/plugins.js @@ -11,7 +11,7 @@ export default function(state, action) { case actionNames.INIT_STATIC_REPORT: { const pluginReducers = []; - plugins.forEach(plugin => { + plugins.forEachPlugin(plugin => { if (Array.isArray(plugin.reducers)) { pluginReducers.push(reduceReducers(state, ...plugin.reducers)); } diff --git a/lib/types.ts b/lib/types.ts index 93d8282bf..bd2b1cfbf 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -171,12 +171,48 @@ export interface ErrorPattern { pattern: string; } +/** + * Plugin configuration. Passed directly to the plugin. + */ +export type PluginConfig = Record; + +export const PLUGIN_COMPONENT_POSITIONS = ['after', 'before', 'wrap'] as const; + +export type PluginComponentPosition = typeof PLUGIN_COMPONENT_POSITIONS[number]; + +/** + * Description of configuration parameters + * @link https://testplane.io/docs/v8/html-reporter/html-reporter-plugins/ + */ export interface PluginDescription { + /** + * The name of the package with the plugin for the report. + * It is assumed that the plugin can be connected using require(name). + */ name: string; + /** + * The name of the React component from the plugin. + */ component: string; + /** + * The name of the extension point in the html-reporter plugin. + */ point?: string; - position?: 'after' | 'before' | 'wrap'; - config?: Record; + /** + * Defines the method by which the component will be placed + * at the extension point of the html-report user interface. + * + * wrap: wrap the extension point in the UI + * + * before: place the component before the extension point + * + * after: place the component after the extension point + */ + position?: PluginComponentPosition; + /** + * Plugin configuration. Passed directly to the plugin. + */ + config?: PluginConfig } export interface CustomGuiItem { diff --git a/test/unit/lib/static/components/extension-point.jsx b/test/unit/lib/static/components/extension-point.jsx index 8fea3f51a..0fd922219 100644 --- a/test/unit/lib/static/components/extension-point.jsx +++ b/test/unit/lib/static/components/extension-point.jsx @@ -27,7 +27,7 @@ describe('', () => { }[component])); const pluginsStub = { - get: pluginsGetStub, + getPluginField: pluginsGetStub, getLoadedConfigs: () => [ {name: 'plugin', component: 'WrapComponent', point: 'example', position: 'wrap'}, {name: 'plugin', component: 'BeforeComponent', point: 'example', position: 'before'}, diff --git a/test/unit/lib/static/modules/plugins.js b/test/unit/lib/static/modules/plugins.js index 17b02ec28..bf3d9512b 100644 --- a/test/unit/lib/static/modules/plugins.js +++ b/test/unit/lib/static/modules/plugins.js @@ -125,14 +125,14 @@ describe('static/modules/plugins', () => { it('should throw when requested plugin is not loaded', async () => { await plugins.loadAll(); - assert.throws(() => plugins.get('plugin-x', 'TestComponent'), 'Plugin "plugin-x" is not loaded.'); + assert.throws(() => plugins.getPluginField('plugin-x', 'TestComponent'), 'Plugin "plugin-x" is not loaded.'); }); it('should throw when specified plugin component does not exist', async () => { loadPluginStub.resolves({}); await plugins.loadAll({pluginsEnabled: true, plugins: [{name: 'plugin-a'}]}); - assert.throws(() => plugins.get('plugin-a', 'TestComponent'), '"TestComponent" is not defined on plugin "plugin-a".'); + assert.throws(() => plugins.getPluginField('plugin-a', 'TestComponent'), '"TestComponent" is not defined on plugin "plugin-a".'); }); it('should return requested component when plugin is loaded', async () => { @@ -140,7 +140,7 @@ describe('static/modules/plugins', () => { loadPluginStub.resolves({TestComponent}); await plugins.loadAll({pluginsEnabled: true, plugins: [{name: 'plugin-a'}]}); - const result = plugins.get('plugin-a', 'TestComponent'); + const result = plugins.getPluginField('plugin-a', 'TestComponent'); assert.strictEqual(result, TestComponent); }); @@ -156,7 +156,7 @@ describe('static/modules/plugins', () => { it('should not call the callback when no plugins are loaded', async () => { await plugins.loadAll(); - plugins.forEach(callbackStub); + plugins.forEachPlugin(callbackStub); assert.notCalled(callbackStub); }); @@ -164,7 +164,7 @@ describe('static/modules/plugins', () => { it('should not call the callback when plugins are enabled but not defined', async () => { await plugins.loadAll({pluginsEnabled: true}); - plugins.forEach(callbackStub); + plugins.forEachPlugin(callbackStub); assert.notCalled(callbackStub); }); @@ -182,7 +182,7 @@ describe('static/modules/plugins', () => { ] }); - plugins.forEach(callbackStub); + plugins.forEachPlugin(callbackStub); assert.notCalled(callbackStub); }); @@ -207,7 +207,7 @@ describe('static/modules/plugins', () => { ] }); - plugins.forEach(callbackStub); + plugins.forEachPlugin(callbackStub); assert.deepStrictEqual(callbackStub.args, [ [pluginsToLoad['plugin-a'], 'plugin-a'], diff --git a/test/unit/lib/static/modules/reducers/plugins.js b/test/unit/lib/static/modules/reducers/plugins.js index dba8e67fa..b6d5ab317 100644 --- a/test/unit/lib/static/modules/reducers/plugins.js +++ b/test/unit/lib/static/modules/reducers/plugins.js @@ -10,7 +10,7 @@ describe('lib/static/modules/reducers/plugins', () => { beforeEach(() => { forEachStub = sandbox.stub(); reducer = proxyquire('lib/static/modules/reducers/plugins', { - '../plugins': {forEach: forEachStub} + '../plugins': {forEachPlugin: forEachStub} }).default; forEachStub.callsFake((callback) => callback({reducers: [