@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
246 lines (228 loc) • 7.23 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';
}