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_BASE_NAMES, SUPPORTED_CONFIG_FILE_EXTENSIONS } 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) {} public async readConfig(cliOptions: PartialStrykerOptions): Promise { 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 { 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 { if (typeof configFileName === 'string') { if (await fileUtils.exists(configFileName)) { return configFileName; } else { throw new ConfigReaderError('File does not exist!', configFileName); } } for (const file of SUPPORTED_CONFIG_FILE_BASE_NAMES) { for (const ext of SUPPORTED_CONFIG_FILE_EXTENSIONS) { if (await fileUtils.exists(`${file}${ext}`)) { return `${file}${ext}`; } } } return undefined; } private async readJsonConfig(configFile: string): Promise { 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 { 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): ${namedExports.map((name) => `"${name}"`).join(', ')}.`; } } } } private async importJSConfigModule(configFile: string): Promise { 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); } }