@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
188 lines (170 loc) • 6.78 kB
text/typescript
import path from 'path';
import fs from 'fs';
import { fileURLToPath, pathToFileURL, URL } from 'url';
import { Logger } from '@stryker-mutator/api/logging';
import { tokens, commonTokens, Plugin, PluginKind } from '@stryker-mutator/api/plugin';
import { notEmpty, propertyPath } from '@stryker-mutator/util';
import { fileUtils } from '../utils/file-utils.js';
import { defaultOptions } from '../config/options-validator.js';
const IGNORED_PACKAGES = ['core', 'api', 'util', 'instrumenter'];
interface PluginModule {
strykerPlugins: Array<Plugin<PluginKind>>;
}
interface SchemaValidationContribution {
strykerValidationSchema: Record<string, unknown>;
}
/**
* Represents a collection of loaded plugins and metadata
*/
export interface LoadedPlugins {
/**
* The JSON schema contributions loaded
*/
schemaContributions: Array<Record<string, unknown>>;
/**
* The actual Stryker plugins loaded, sorted by type
*/
pluginsByKind: Map<PluginKind, Array<Plugin<PluginKind>>>;
/**
* The import specifiers or full URL paths to the actual plugins
*/
pluginModulePaths: string[];
}
/**
* Can resolve modules and pull them into memory
*/
export class PluginLoader {
public static inject = tokens(commonTokens.logger);
constructor(private readonly log: Logger) {}
/**
* Loads plugins based on configured plugin descriptors.
* A plugin descriptor can be:
* * A full url: "file:///home/nicojs/github/my-plugin.js"
* * An absolute file path: "/home/nicojs/github/my-plugin.js"
* * A relative path: "./my-plugin.js"
* * A bare import expression: "@stryker-mutator/karma-runner"
* * A simple glob expression (only wild cards are supported): "@stryker-mutator/*"
*/
public async load(pluginDescriptors: readonly string[]): Promise<LoadedPlugins> {
const pluginModules = await this.resolvePluginModules(pluginDescriptors);
const loadedPluginModules = (
await Promise.all(
pluginModules.map(async (moduleName) => {
const plugin = await this.loadPlugin(moduleName);
return {
...plugin,
moduleName,
};
}),
)
).filter(notEmpty);
const result: LoadedPlugins = { schemaContributions: [], pluginsByKind: new Map<PluginKind, Array<Plugin<PluginKind>>>(), pluginModulePaths: [] };
loadedPluginModules.forEach(({ plugins, schemaContribution, moduleName }) => {
if (plugins) {
result.pluginModulePaths.push(moduleName);
plugins.forEach((plugin) => {
const pluginsForKind = result.pluginsByKind.get(plugin.kind);
if (pluginsForKind) {
pluginsForKind.push(plugin);
} else {
result.pluginsByKind.set(plugin.kind, [plugin]);
}
});
}
if (schemaContribution) {
result.schemaContributions.push(schemaContribution);
}
});
return result;
}
private async resolvePluginModules(pluginDescriptors: readonly string[]): Promise<string[]> {
return (
await Promise.all(
pluginDescriptors.map(async (pluginExpression) => {
if (pluginExpression.includes('*')) {
return await this.globPluginModules(pluginExpression);
} else if (path.isAbsolute(pluginExpression) || pluginExpression.startsWith('.')) {
return pathToFileURL(path.resolve(pluginExpression)).toString();
} else {
// Bare plugin expression like "@stryker-mutator/mocha-runner" (or file URL)
return pluginExpression;
}
}),
)
)
.filter(notEmpty)
.flat();
}
private async globPluginModules(pluginExpression: string) {
const { org, pkg } = parsePluginExpression(pluginExpression);
const pluginDirectory = path.resolve(fileURLToPath(new URL('../../../../../', import.meta.url)), org);
const regexp = new RegExp('^' + pkg.replace('*', '.*'));
this.log.debug('Loading %s from %s', pluginExpression, pluginDirectory);
const plugins = (await fs.promises.readdir(pluginDirectory))
.filter((pluginName) => !IGNORED_PACKAGES.includes(pluginName) && regexp.test(pluginName))
.map((pluginName) => `${org.length ? `${org}/` : ''}${pluginName}`);
if (plugins.length === 0 && !defaultOptions.plugins.includes(pluginExpression)) {
this.log.warn('Expression "%s" not resulted in plugins to load.', pluginExpression);
}
plugins.forEach((plugin) => this.log.debug('Loading plugin "%s" (matched with expression %s)', plugin, pluginExpression));
return plugins;
}
private async loadPlugin(
descriptor: string,
): Promise<{ plugins: Array<Plugin<PluginKind>> | undefined; schemaContribution: Record<string, unknown> | undefined } | undefined> {
this.log.debug('Loading plugin %s', descriptor);
try {
const module = await fileUtils.importModule(descriptor);
const plugins = isPluginModule(module) ? module.strykerPlugins : undefined;
const schemaContribution = hasValidationSchemaContribution(module) ? module.strykerValidationSchema : undefined;
if (plugins ?? schemaContribution) {
return {
plugins,
schemaContribution,
};
} else {
this.log.warn(
'Module "%s" did not contribute a StrykerJS plugin. It didn\'t export a "%s" or "%s".',
descriptor,
propertyPath<PluginModule>()('strykerPlugins'),
propertyPath<SchemaValidationContribution>()('strykerValidationSchema'),
);
}
} catch (e: any) {
if (e.code === 'ERR_MODULE_NOT_FOUND' && e.message.indexOf(descriptor) !== -1) {
this.log.warn('Cannot find plugin "%s".\n Did you forget to install it ?', descriptor);
} else {
this.log.warn('Error during loading "%s" plugin:\n %s', descriptor, e.message);
}
}
return;
}
}
/**
* Distills organization name from a package expression.
* @example
* '@stryker-mutator/core' => { org: '@stryker-mutator', 'core' }
* 'glob' => { org: '', 'glob' }
*/
function parsePluginExpression(pluginExpression: string) {
const parts = pluginExpression.split('/');
if (parts.length > 1) {
return {
org: parts.slice(0, parts.length - 1).join('/'),
pkg: parts[parts.length - 1],
};
} else {
return {
org: '',
pkg: parts[0],
};
}
}
function isPluginModule(module: unknown): module is PluginModule {
const pluginModule = module as Partial<PluginModule>;
return Array.isArray(pluginModule.strykerPlugins);
}
function hasValidationSchemaContribution(module: unknown): module is SchemaValidationContribution {
const pluginModule = module as Partial<SchemaValidationContribution>;
return typeof pluginModule.strykerValidationSchema === 'object';
}