@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
194 lines (173 loc) • 5.93 kB
text/typescript
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
import {
PartialStrykerOptions,
StrykerOptions,
} from '@stryker-mutator/api/core';
import { Logger } from '@stryker-mutator/api/logging';
import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
import { deepMerge, I } from '@stryker-mutator/util';
import { coreTokens } from '../di/index.js';
import { ConfigError } from '../errors.js';
import { fileUtils } from '../utils/file-utils.js';
import { OptionsValidator } from './options-validator.js';
import { SUPPORTED_CONFIG_FILE_NAMES } from './config-file-formats.js';
export const CONFIG_SYNTAX_HELP = `
Example of how a config file should look:
/**
* @type {import('@stryker-mutator/api/core').StrykerOptions}
*/
export default {
// You're options here!
}
Or using commonjs:
/**
* @type {import('@stryker-mutator/api/core').StrykerOptions}
*/
module.exports = {
// You're options here!
}
See https://stryker-mutator.io/docs/stryker-js/config-file for more information.`.trim();
export class ConfigReader {
public static inject = tokens(
commonTokens.logger,
coreTokens.optionsValidator,
);
constructor(
private readonly log: Logger,
private readonly validator: I<OptionsValidator>,
) {}
public async readConfig(
cliOptions: PartialStrykerOptions,
): Promise<StrykerOptions> {
const options = await this.loadOptionsFromConfigFile(cliOptions);
// merge the config from config file and cliOptions (precedence)
deepMerge(options, cliOptions);
this.validator.validate(options);
if (this.log.isDebugEnabled()) {
this.log.debug(`Loaded config: ${JSON.stringify(options, null, 2)}`);
}
return options;
}
private async loadOptionsFromConfigFile(
cliOptions: PartialStrykerOptions,
): Promise<PartialStrykerOptions> {
const configFile = await this.findConfigFile(cliOptions.configFile);
if (!configFile) {
this.log.info(
'No config file specified. Running with command line arguments.',
);
this.log.info('Use `stryker init` command to generate your config file.');
return {};
}
this.log.debug(`Loading config from ${configFile}`);
if (path.extname(configFile).toLocaleLowerCase() === '.json') {
return this.readJsonConfig(configFile);
} else {
return this.importJSConfig(configFile);
}
}
private async findConfigFile(
configFileName: unknown,
): Promise<string | undefined> {
if (typeof configFileName === 'string') {
if (await fileUtils.exists(configFileName)) {
return configFileName;
} else {
throw new ConfigReaderError('File does not exist!', configFileName);
}
}
for (const fileName of SUPPORTED_CONFIG_FILE_NAMES) {
if (await fileUtils.exists(fileName)) {
return fileName;
}
}
return undefined;
}
private async readJsonConfig(
configFile: string,
): Promise<PartialStrykerOptions> {
const fileContent = await fs.promises.readFile(configFile, 'utf-8');
try {
return JSON.parse(fileContent);
} catch (err) {
throw new ConfigReaderError(
'File contains invalid JSON',
configFile,
err,
);
}
}
private async importJSConfig(
configFile: string,
): Promise<PartialStrykerOptions> {
const importedModule = await this.importJSConfigModule(configFile);
if (this.hasDefaultExport(importedModule)) {
const maybeOptions = importedModule.default;
if (typeof maybeOptions !== 'object') {
if (typeof maybeOptions === 'function') {
this.log.fatal(
`Invalid config file. Exporting a function is no longer supported. Please export an object with your configuration instead, or use a "stryker.conf.json" file.\n${CONFIG_SYNTAX_HELP}`,
);
} else {
this.log.fatal(
`Invalid config file. It must export an object, found a "${typeof maybeOptions}"!\n${CONFIG_SYNTAX_HELP}`,
);
}
throw new ConfigReaderError(
'Default export of config file must be an object!',
configFile,
);
}
if (!maybeOptions || !Object.keys(maybeOptions).length) {
this.log.warn(
`Stryker options were empty. Did you forget to export options from ${configFile}?`,
);
}
return { ...maybeOptions } as PartialStrykerOptions;
} else {
this.log.fatal(
`Invalid config file. It is missing a default export. ${describeNamedExports()}\n${CONFIG_SYNTAX_HELP}`,
);
throw new ConfigReaderError(
'Config file must have a default export!',
configFile,
);
function describeNamedExports() {
const namedExports: string[] =
(typeof importedModule === 'object' &&
Object.keys(importedModule ?? {})) ||
[];
if (namedExports.length === 0) {
return "In fact, it didn't export anything.";
} else {
return `Found named export(s): ${new Intl.ListFormat('en').format(namedExports.map((name) => `"${name}"`))}.`;
}
}
}
}
private async importJSConfigModule(configFile: string): Promise<unknown> {
try {
return await fileUtils.importModule(
pathToFileURL(path.resolve(configFile)).toString(),
);
} catch (err) {
throw new ConfigReaderError('Error during import', configFile, err);
}
}
private hasDefaultExport(
importedModule: unknown,
): importedModule is { default: unknown } {
return importedModule &&
typeof importedModule === 'object' &&
'default' in importedModule
? true
: false;
}
}
export class ConfigReaderError extends ConfigError {
constructor(message: string, configFileName: string, cause?: unknown) {
super(`Invalid config file "${configFileName}". ${message}`, cause);
}
}