1 | import path from 'path';
|
2 | import fs from 'fs';
|
3 | import { fileURLToPath, pathToFileURL, URL } from 'url';
|
4 |
|
5 | import { Logger } from '@stryker-mutator/api/logging';
|
6 | import { tokens, commonTokens, Plugin, PluginKind } from '@stryker-mutator/api/plugin';
|
7 | import { notEmpty, propertyPath } from '@stryker-mutator/util';
|
8 |
|
9 | import { fileUtils } from '../utils/file-utils.js';
|
10 | import { defaultOptions } from '../config/options-validator.js';
|
11 |
|
12 | const IGNORED_PACKAGES = ['core', 'api', 'util', 'instrumenter'];
|
13 |
|
14 | interface PluginModule {
|
15 | strykerPlugins: Array<Plugin<PluginKind>>;
|
16 | }
|
17 |
|
18 | interface SchemaValidationContribution {
|
19 | strykerValidationSchema: Record<string, unknown>;
|
20 | }
|
21 |
|
22 |
|
23 |
|
24 |
|
25 | export interface LoadedPlugins {
|
26 | |
27 |
|
28 |
|
29 | schemaContributions: Array<Record<string, unknown>>;
|
30 | |
31 |
|
32 |
|
33 | pluginsByKind: Map<PluginKind, Array<Plugin<PluginKind>>>;
|
34 | |
35 |
|
36 |
|
37 | pluginModulePaths: string[];
|
38 | }
|
39 |
|
40 |
|
41 |
|
42 |
|
43 | export class PluginLoader {
|
44 | public static inject = tokens(commonTokens.logger);
|
45 | constructor(private readonly log: Logger) {}
|
46 |
|
47 | |
48 |
|
49 |
|
50 |
|
51 |
|
52 |
|
53 |
|
54 |
|
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 |
|
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 | */
|
164 | function 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 |
|
179 | function isPluginModule(module: unknown): module is PluginModule {
|
180 | const pluginModule = module as Partial<PluginModule>;
|
181 | return Array.isArray(pluginModule.strykerPlugins);
|
182 | }
|
183 |
|
184 | function 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 |