UNPKG

6.78 kBPlain TextView Raw
1import path from 'path';
2import fs from 'fs';
3import { fileURLToPath, pathToFileURL, URL } from 'url';
4
5import { Logger } from '@stryker-mutator/api/logging';
6import { tokens, commonTokens, Plugin, PluginKind } from '@stryker-mutator/api/plugin';
7import { notEmpty, propertyPath } from '@stryker-mutator/util';
8
9import { fileUtils } from '../utils/file-utils.js';
10import { defaultOptions } from '../config/options-validator.js';
11
12const IGNORED_PACKAGES = ['core', 'api', 'util', 'instrumenter'];
13
14interface PluginModule {
15 strykerPlugins: Array<Plugin<PluginKind>>;
16}
17
18interface SchemaValidationContribution {
19 strykerValidationSchema: Record<string, unknown>;
20}
21
22/**
23 * Represents a collection of loaded plugins and metadata
24 */
25export interface LoadedPlugins {
26 /**
27 * The JSON schema contributions loaded
28 */
29 schemaContributions: Array<Record<string, unknown>>;
30 /**
31 * The actual Stryker plugins loaded, sorted by type
32 */
33 pluginsByKind: Map<PluginKind, Array<Plugin<PluginKind>>>;
34 /**
35 * The import specifiers or full URL paths to the actual plugins
36 */
37 pluginModulePaths: string[];
38}
39
40/**
41 * Can resolve modules and pull them into memory
42 */
43export class PluginLoader {
44 public static inject = tokens(commonTokens.logger);
45 constructor(private readonly log: Logger) {}
46
47 /**
48 * Loads plugins based on configured plugin descriptors.
49 * A plugin descriptor can be:
50 * * A full url: "file:///home/nicojs/github/my-plugin.js"
51 * * An absolute file path: "/home/nicojs/github/my-plugin.js"
52 * * A relative path: "./my-plugin.js"
53 * * A bare import expression: "@stryker-mutator/karma-runner"
54 * * A simple glob expression (only wild cards are supported): "@stryker-mutator/*"
55 */
56 public async load(pluginDescriptors: readonly string[]): Promise<LoadedPlugins> {
57 const pluginModules = await this.resolvePluginModules(pluginDescriptors);
58 const loadedPluginModules = (
59 await Promise.all(
60 pluginModules.map(async (moduleName) => {
61 const plugin = await this.loadPlugin(moduleName);
62 return {
63 ...plugin,
64 moduleName,
65 };
66 })
67 )
68 ).filter(notEmpty);
69
70 const result: LoadedPlugins = { schemaContributions: [], pluginsByKind: new Map<PluginKind, Array<Plugin<PluginKind>>>(), pluginModulePaths: [] };
71
72 loadedPluginModules.forEach(({ plugins, schemaContribution, moduleName }) => {
73 if (plugins) {
74 result.pluginModulePaths.push(moduleName);
75 plugins.forEach((plugin) => {
76 const pluginsForKind = result.pluginsByKind.get(plugin.kind);
77 if (pluginsForKind) {
78 pluginsForKind.push(plugin);
79 } else {
80 result.pluginsByKind.set(plugin.kind, [plugin]);
81 }
82 });
83 }
84 if (schemaContribution) {
85 result.schemaContributions.push(schemaContribution);
86 }
87 });
88 return result;
89 }
90
91 private async resolvePluginModules(pluginDescriptors: readonly string[]): Promise<string[]> {
92 return (
93 await Promise.all(
94 pluginDescriptors.map(async (pluginExpression) => {
95 if (pluginExpression.includes('*')) {
96 return await this.globPluginModules(pluginExpression);
97 } else if (path.isAbsolute(pluginExpression) || pluginExpression.startsWith('.')) {
98 return pathToFileURL(path.resolve(pluginExpression)).toString();
99 } else {
100 // Bare plugin expression like "@stryker-mutator/mocha-runner" (or file URL)
101 return pluginExpression;
102 }
103 })
104 )
105 )
106 .filter(notEmpty)
107 .flat();
108 }
109
110 private async globPluginModules(pluginExpression: string) {
111 const { org, pkg } = parsePluginExpression(pluginExpression);
112
113 const pluginDirectory = path.resolve(fileURLToPath(new URL('../../../../../', import.meta.url)), org);
114 const regexp = new RegExp('^' + pkg.replace('*', '.*'));
115 this.log.debug('Loading %s from %s', pluginExpression, pluginDirectory);
116 const plugins = (await fs.promises.readdir(pluginDirectory))
117 .filter((pluginName) => !IGNORED_PACKAGES.includes(pluginName) && regexp.test(pluginName))
118 .map((pluginName) => `${org.length ? `${org}/` : ''}${pluginName}`);
119 if (plugins.length === 0 && !defaultOptions.plugins.includes(pluginExpression)) {
120 this.log.warn('Expression "%s" not resulted in plugins to load.', pluginExpression);
121 }
122 plugins.forEach((plugin) => this.log.debug('Loading plugin "%s" (matched with expression %s)', plugin, pluginExpression));
123 return plugins;
124 }
125
126 private async loadPlugin(
127 descriptor: string
128 ): Promise<{ plugins: Array<Plugin<PluginKind>> | undefined; schemaContribution: Record<string, unknown> | undefined } | undefined> {
129 this.log.debug('Loading plugin %s', descriptor);
130 try {
131 const module = await fileUtils.importModule(descriptor);
132 const plugins = isPluginModule(module) ? module.strykerPlugins : undefined;
133 const schemaContribution = hasValidationSchemaContribution(module) ? module.strykerValidationSchema : undefined;
134 if (plugins || schemaContribution) {
135 return {
136 plugins,
137 schemaContribution,
138 };
139 } else {
140 this.log.warn(
141 'Module "%s" did not contribute a StrykerJS plugin. It didn\'t export a "%s" or "%s".',
142 descriptor,
143 propertyPath<PluginModule>()('strykerPlugins'),
144 propertyPath<SchemaValidationContribution>()('strykerValidationSchema')
145 );
146 }
147 } catch (e: any) {
148 if (e.code === 'ERR_MODULE_NOT_FOUND' && e.message.indexOf(descriptor) !== -1) {
149 this.log.warn('Cannot find plugin "%s".\n Did you forget to install it ?', descriptor);
150 } else {
151 this.log.warn('Error during loading "%s" plugin:\n %s', descriptor, e.message);
152 }
153 }
154 return;
155 }
156}
157
158/**
159 * Distills organization name from a package expression.
160 * @example
161 * '@stryker-mutator/core' => { org: '@stryker-mutator', 'core' }
162 * 'glob' => { org: '', 'glob' }
163 */
164function parsePluginExpression(pluginExpression: string) {
165 const parts = pluginExpression.split('/');
166 if (parts.length > 1) {
167 return {
168 org: parts.slice(0, parts.length - 1).join('/'),
169 pkg: parts[parts.length - 1],
170 };
171 } else {
172 return {
173 org: '',
174 pkg: parts[0],
175 };
176 }
177}
178
179function isPluginModule(module: unknown): module is PluginModule {
180 const pluginModule = module as Partial<PluginModule>;
181 return Array.isArray(pluginModule.strykerPlugins);
182}
183
184function hasValidationSchemaContribution(module: unknown): module is SchemaValidationContribution {
185 const pluginModule = module as Partial<SchemaValidationContribution>;
186 return typeof pluginModule.strykerValidationSchema === 'object';
187}
188
\No newline at end of file