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: [