UNPKG

11.4 kBPlain TextView Raw
1import os from 'os';
2import path from 'path';
3
4import glob from 'glob';
5import Ajv, { ValidateFunction } from 'ajv';
6import { StrykerOptions, strykerCoreSchema } from '@stryker-mutator/api/core';
7import { tokens, commonTokens } from '@stryker-mutator/api/plugin';
8import { noopLogger, findUnserializables, Immutable, deepFreeze } from '@stryker-mutator/util';
9import { Logger } from '@stryker-mutator/api/logging';
10import type { JSONSchema7 } from 'json-schema';
11
12import { coreTokens } from '../di/index.js';
13import { ConfigError } from '../errors.js';
14import { objectUtils, optionsPath } from '../utils/index.js';
15import { CommandTestRunner } from '../test-runner/command-test-runner.js';
16import { IGNORE_PATTERN_CHARACTER, MUTATION_RANGE_REGEX } from '../fs/index.js';
17
18import { describeErrors } from './validation-errors.js';
19
20const ajv = new Ajv({ useDefaults: true, allErrors: true, jsPropertySyntax: true, verbose: true, logger: false, strict: false });
21
22export 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 * Validates the provided options, throwing an error if something is wrong.
33 * Optionally also warns about excess or unserializable options.
34 * @param options The stryker options to validate
35 * @param mark Wether or not to log warnings on unknown properties or unserializable properties
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 // @ts-expect-error mutator.name
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 // @ts-expect-error mutator.name
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 // @ts-expect-error jest.enableBail
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 // @ts-expect-error jest.enableBail
105 rawOptions.disableBail = !rawOptions.jest?.enableBail;
106 // @ts-expect-error jest.enableBail
107 delete rawOptions.jest.enableBail;
108 }
109
110 // @ts-expect-error htmlReporter.baseDir
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 // @ts-expect-error htmlReporter.baseDir
119 if (!rawOptions.htmlReporter.fileName) {
120 // @ts-expect-error htmlReporter.baseDir
121 rawOptions.htmlReporter.fileName = path.join(String(rawOptions.htmlReporter.baseDir), 'index.html');
122 }
123 // @ts-expect-error htmlReporter.baseDir
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
245export 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
252export const defaultOptions: Immutable<StrykerOptions> = deepFreeze(createDefaultOptions());