UNPKG

@stryker-mutator/core

Version:

The extendable JavaScript mutation testing framework

188 lines (170 loc) 6.78 kB
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'; }