UNPKG

@stryker-mutator/core

Version:

The extendable JavaScript mutation testing framework

246 lines (228 loc) 7.23 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'; }