1 | import os from 'os';
|
2 | import path from 'path';
|
3 |
|
4 | import glob from 'glob';
|
5 | import Ajv, { ValidateFunction } from 'ajv';
|
6 | import { StrykerOptions, strykerCoreSchema } from '@stryker-mutator/api/core';
|
7 | import { tokens, commonTokens } from '@stryker-mutator/api/plugin';
|
8 | import { noopLogger, findUnserializables, Immutable, deepFreeze } from '@stryker-mutator/util';
|
9 | import { Logger } from '@stryker-mutator/api/logging';
|
10 | import type { JSONSchema7 } from 'json-schema';
|
11 |
|
12 | import { coreTokens } from '../di/index.js';
|
13 | import { ConfigError } from '../errors.js';
|
14 | import { objectUtils, optionsPath } from '../utils/index.js';
|
15 | import { CommandTestRunner } from '../test-runner/command-test-runner.js';
|
16 | import { IGNORE_PATTERN_CHARACTER, MUTATION_RANGE_REGEX } from '../fs/index.js';
|
17 |
|
18 | import { describeErrors } from './validation-errors.js';
|
19 |
|
20 | const ajv = new Ajv({ useDefaults: true, allErrors: true, jsPropertySyntax: true, verbose: true, logger: false, strict: false });
|
21 |
|
22 | export class OptionsValidator {
|
23 | private readonly validateFn: ValidateFunction;
|
24 |
|
25 | public static readonly inject = tokens(coreTokens.validationSchema, commonTokens.logger);
|
26 |
|
27 | constructor(private readonly schema: JSONSchema7, private readonly log: Logger) {
|
28 | this.validateFn = ajv.compile(schema);
|
29 | }
|
30 |
|
31 | |
32 |
|
33 |
|
34 |
|
35 |
|
36 |
|
37 | public validate(options: Record<string, unknown>, mark = false): asserts options is StrykerOptions {
|
38 | this.removeDeprecatedOptions(options);
|
39 | this.schemaValidate(options);
|
40 | this.customValidation(options);
|
41 | if (mark) {
|
42 | this.markOptions(options);
|
43 | }
|
44 | }
|
45 |
|
46 | private removeDeprecatedOptions(rawOptions: Record<string, unknown>) {
|
47 | if (typeof rawOptions.mutator === 'string') {
|
48 | this.log.warn(
|
49 | 'DEPRECATED. Use of "mutator" as string is no longer needed. You can remove it from your configuration. Stryker now supports mutating of JavaScript and friend files out of the box.'
|
50 | );
|
51 | delete rawOptions.mutator;
|
52 | }
|
53 |
|
54 | if (typeof rawOptions.mutator === 'object' && rawOptions.mutator.name) {
|
55 | this.log.warn(
|
56 | 'DEPRECATED. Use of "mutator.name" is no longer needed. You can remove "mutator.name" from your configuration. Stryker now supports mutating of JavaScript and friend files out of the box.'
|
57 | );
|
58 |
|
59 | delete rawOptions.mutator.name;
|
60 | }
|
61 | if (Object.keys(rawOptions).includes('testFramework')) {
|
62 | this.log.warn(
|
63 | 'DEPRECATED. Use of "testFramework" is no longer needed. You can remove it from your configuration. Your test runner plugin now handles its own test framework integration.'
|
64 | );
|
65 | delete rawOptions.testFramework;
|
66 | }
|
67 | if (Array.isArray(rawOptions.transpilers)) {
|
68 | const example = rawOptions.transpilers.includes('babel')
|
69 | ? 'babel src --out-dir lib'
|
70 | : rawOptions.transpilers.includes('typescript')
|
71 | ? 'tsc -b'
|
72 | : rawOptions.transpilers.includes('webpack')
|
73 | ? 'webpack --config webpack.config.js'
|
74 | : 'npm run build';
|
75 | this.log.warn(
|
76 | `DEPRECATED. Support for "transpilers" is removed. You can now configure your own "${optionsPath('buildCommand')}". For example, ${example}.`
|
77 | );
|
78 | delete rawOptions.transpilers;
|
79 | }
|
80 | if (Array.isArray(rawOptions.files)) {
|
81 | const ignorePatternsName = optionsPath('ignorePatterns');
|
82 | const isString = (uncertain: unknown): uncertain is string => typeof uncertain === 'string';
|
83 | const files = rawOptions.files.filter(isString);
|
84 | const newIgnorePatterns: string[] = [
|
85 | '**',
|
86 | ...files.map((filePattern) =>
|
87 | filePattern.startsWith(IGNORE_PATTERN_CHARACTER) ? filePattern.substr(1) : `${IGNORE_PATTERN_CHARACTER}${filePattern}`
|
88 | ),
|
89 | ];
|
90 | delete rawOptions.files;
|
91 | this.log.warn(
|
92 | `DEPRECATED. Use of "files" is deprecated, please use "${ignorePatternsName}" instead (or remove "files" altogether will probably work as well). For now, rewriting them as ${JSON.stringify(
|
93 | newIgnorePatterns
|
94 | )}. See https://stryker-mutator.io/docs/stryker-js/configuration/#ignorepatterns-string`
|
95 | );
|
96 | const existingIgnorePatterns = Array.isArray(rawOptions[ignorePatternsName]) ? (rawOptions[ignorePatternsName] as unknown[]) : [];
|
97 | rawOptions[ignorePatternsName] = [...newIgnorePatterns, ...existingIgnorePatterns];
|
98 | }
|
99 |
|
100 | if (rawOptions.jest?.enableBail !== undefined) {
|
101 | this.log.warn(
|
102 | 'DEPRECATED. Use of "jest.enableBail" is deprecated, please use "disableBail" instead. See https://stryker-mutator.io/docs/stryker-js/configuration#disablebail-boolean'
|
103 | );
|
104 |
|
105 | rawOptions.disableBail = !rawOptions.jest?.enableBail;
|
106 |
|
107 | delete rawOptions.jest.enableBail;
|
108 | }
|
109 |
|
110 |
|
111 | if (rawOptions.htmlReporter?.baseDir) {
|
112 | this.log.warn(
|
113 | `DEPRECATED. Use of "htmlReporter.baseDir" is deprecated, please use "${optionsPath(
|
114 | 'htmlReporter',
|
115 | 'fileName'
|
116 | )}" instead. See https://stryker-mutator.io/docs/stryker-js/configuration/#reporters-string`
|
117 | );
|
118 |
|
119 | if (!rawOptions.htmlReporter.fileName) {
|
120 |
|
121 | rawOptions.htmlReporter.fileName = path.join(String(rawOptions.htmlReporter.baseDir), 'index.html');
|
122 | }
|
123 |
|
124 | delete rawOptions.htmlReporter.baseDir;
|
125 | }
|
126 | }
|
127 |
|
128 | private customValidation(options: StrykerOptions) {
|
129 | const additionalErrors: string[] = [];
|
130 | if (options.thresholds.high < options.thresholds.low) {
|
131 | additionalErrors.push('Config option "thresholds.high" should be higher than "thresholds.low".');
|
132 | }
|
133 | if (options.maxConcurrentTestRunners !== Number.MAX_SAFE_INTEGER) {
|
134 | this.log.warn('DEPRECATED. Use of "maxConcurrentTestRunners" is deprecated. Please use "concurrency" instead.');
|
135 | if (!options.concurrency && options.maxConcurrentTestRunners < os.cpus().length - 1) {
|
136 | options.concurrency = options.maxConcurrentTestRunners;
|
137 | }
|
138 | }
|
139 | if (CommandTestRunner.is(options.testRunner)) {
|
140 | if (options.testRunnerNodeArgs.length) {
|
141 | this.log.warn(
|
142 | 'Using "testRunnerNodeArgs" together with the "command" test runner is not supported, these arguments will be ignored. You can add your custom arguments by setting the "commandRunner.command" option.'
|
143 | );
|
144 | }
|
145 | }
|
146 | if (options.ignoreStatic && options.coverageAnalysis !== 'perTest') {
|
147 | additionalErrors.push(
|
148 | `Config option "${optionsPath('ignoreStatic')}" is not supported with coverage analysis "${
|
149 | options.coverageAnalysis
|
150 | }". Either turn off "${optionsPath('ignoreStatic')}", or configure "${optionsPath('coverageAnalysis')}" to be "perTest".`
|
151 | );
|
152 | }
|
153 | options.mutate.forEach((mutateString, index) => {
|
154 | const match = MUTATION_RANGE_REGEX.exec(mutateString);
|
155 | if (match) {
|
156 | if (glob.hasMagic(mutateString)) {
|
157 | additionalErrors.push(
|
158 | `Config option "mutate[${index}]" is invalid. Cannot combine a glob expression with a mutation range in "${mutateString}".`
|
159 | );
|
160 | } else {
|
161 | const [_, _fileName, mutationRange, startLine, _startColumn, endLine, _endColumn] = match;
|
162 | const start = parseInt(startLine, 10);
|
163 | const end = parseInt(endLine, 10);
|
164 | if (start < 1) {
|
165 | additionalErrors.push(
|
166 | `Config option "mutate[${index}]" is invalid. Mutation range "${mutationRange}" is invalid, line ${start} does not exist (lines start at 1).`
|
167 | );
|
168 | }
|
169 | if (start > end) {
|
170 | additionalErrors.push(
|
171 | `Config option "mutate[${index}]" is invalid. Mutation range "${mutationRange}" is invalid. The "from" line number (${start}) should be less then the "to" line number (${end}).`
|
172 | );
|
173 | }
|
174 | }
|
175 | }
|
176 | });
|
177 |
|
178 | additionalErrors.forEach((error) => this.log.error(error));
|
179 | this.throwErrorIfNeeded(additionalErrors);
|
180 | }
|
181 |
|
182 | private schemaValidate(options: unknown): asserts options is StrykerOptions {
|
183 | if (!this.validateFn(options)) {
|
184 | const describedErrors = describeErrors(this.validateFn.errors!);
|
185 | describedErrors.forEach((error) => this.log.error(error));
|
186 | this.throwErrorIfNeeded(describedErrors);
|
187 | }
|
188 | }
|
189 |
|
190 | private throwErrorIfNeeded(errors: string[]) {
|
191 | if (errors.length > 0) {
|
192 | throw new ConfigError(
|
193 | errors.length === 1 ? 'Please correct this configuration error and try again.' : 'Please correct these configuration errors and try again.'
|
194 | );
|
195 | }
|
196 | }
|
197 |
|
198 | private markOptions(options: StrykerOptions): void {
|
199 | this.markExcessOptions(options);
|
200 | this.markUnserializableOptions(options);
|
201 | }
|
202 |
|
203 | private markExcessOptions(options: StrykerOptions) {
|
204 | const OPTIONS_ADDED_BY_STRYKER = ['set', 'configFile', '$schema'];
|
205 |
|
206 | if (objectUtils.isWarningEnabled('unknownOptions', options.warnings)) {
|
207 | const schemaKeys = Object.keys(this.schema.properties!);
|
208 | const excessPropertyNames = Object.keys(options)
|
209 | .filter((key) => !key.endsWith('_comment'))
|
210 | .filter((key) => !OPTIONS_ADDED_BY_STRYKER.includes(key))
|
211 | .filter((key) => !schemaKeys.includes(key));
|
212 |
|
213 | if (excessPropertyNames.length) {
|
214 | excessPropertyNames.forEach((excessPropertyName) => {
|
215 | this.log.warn(`Unknown stryker config option "${excessPropertyName}".`);
|
216 | });
|
217 |
|
218 | this.log.warn(`Possible causes:
|
219 | * Is it a typo on your end?
|
220 | * Did you only write this property as a comment? If so, please postfix it with "_comment".
|
221 | * You might be missing a plugin that is supposed to use it. Stryker loaded plugins from: ${JSON.stringify(options.plugins)}
|
222 | * The plugin that is using it did not contribute explicit validation.
|
223 | (disable "${optionsPath('warnings', 'unknownOptions')}" to ignore this warning)`);
|
224 | }
|
225 | }
|
226 | }
|
227 |
|
228 | private markUnserializableOptions(options: StrykerOptions) {
|
229 | if (objectUtils.isWarningEnabled('unserializableOptions', options.warnings)) {
|
230 | const unserializables = findUnserializables(options);
|
231 | if (unserializables) {
|
232 | unserializables.forEach((unserializable) =>
|
233 | this.log.warn(
|
234 | `Config option "${unserializable.path.join('.')}" is not (fully) serializable. ${
|
235 | unserializable.reason
|
236 | }. Any test runner or checker worker processes might not receive this value as intended.`
|
237 | )
|
238 | );
|
239 | this.log.warn(`(disable ${optionsPath('warnings', 'unserializableOptions')} to ignore this warning)`);
|
240 | }
|
241 | }
|
242 | }
|
243 | }
|
244 |
|
245 | export function createDefaultOptions(): StrykerOptions {
|
246 | const options: Record<string, unknown> = {};
|
247 | const validator: OptionsValidator = new OptionsValidator(strykerCoreSchema, noopLogger);
|
248 | validator.validate(options);
|
249 | return options;
|
250 | }
|
251 |
|
252 | export const defaultOptions: Immutable<StrykerOptions> = deepFreeze(createDefaultOptions());
|