1 | import fs from 'fs';
|
2 | import path from 'path';
|
3 | import { pathToFileURL } from 'url';
|
4 |
|
5 | import { PartialStrykerOptions, StrykerOptions } from '@stryker-mutator/api/core';
|
6 | import { Logger } from '@stryker-mutator/api/logging';
|
7 | import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
|
8 | import { deepMerge, I } from '@stryker-mutator/util';
|
9 |
|
10 | import { coreTokens } from '../di/index.js';
|
11 | import { ConfigError } from '../errors.js';
|
12 | import { fileUtils } from '../utils/file-utils.js';
|
13 |
|
14 | import { OptionsValidator } from './options-validator.js';
|
15 | import { SUPPORTED_CONFIG_FILE_BASE_NAMES, SUPPORTED_CONFIG_FILE_EXTENSIONS } from './config-file-formats.js';
|
16 |
|
17 | export const CONFIG_SYNTAX_HELP = `
|
18 | Example of how a config file should look:
|
19 | /**
|
20 | * @type {import('@stryker-mutator/api/core').StrykerOptions}
|
21 | */
|
22 | export default {
|
23 | // You're options here!
|
24 | }
|
25 |
|
26 | Or using commonjs:
|
27 | /**
|
28 | * @type {import('@stryker-mutator/api/core').StrykerOptions}
|
29 | */
|
30 | module.exports = {
|
31 | // You're options here!
|
32 | }
|
33 |
|
34 | See https://stryker-mutator.io/docs/stryker-js/config-file for more information.`.trim();
|
35 |
|
36 | export class ConfigReader {
|
37 | public static inject = tokens(commonTokens.logger, coreTokens.optionsValidator);
|
38 | constructor(private readonly log: Logger, private readonly validator: I<OptionsValidator>) {}
|
39 |
|
40 | public async readConfig(cliOptions: PartialStrykerOptions): Promise<StrykerOptions> {
|
41 | const options = await this.loadOptionsFromConfigFile(cliOptions);
|
42 |
|
43 |
|
44 | deepMerge(options, cliOptions);
|
45 | this.validator.validate(options);
|
46 | if (this.log.isDebugEnabled()) {
|
47 | this.log.debug(`Loaded config: ${JSON.stringify(options, null, 2)}`);
|
48 | }
|
49 | return options;
|
50 | }
|
51 |
|
52 | private async loadOptionsFromConfigFile(cliOptions: PartialStrykerOptions): Promise<PartialStrykerOptions> {
|
53 | const configFile = await this.findConfigFile(cliOptions.configFile);
|
54 | if (!configFile) {
|
55 | this.log.info('No config file specified. Running with command line arguments.');
|
56 | this.log.info('Use `stryker init` command to generate your config file.');
|
57 | return {};
|
58 | }
|
59 | this.log.debug(`Loading config from ${configFile}`);
|
60 |
|
61 | if (path.extname(configFile).toLocaleLowerCase() === '.json') {
|
62 | return this.readJsonConfig(configFile);
|
63 | } else {
|
64 | return this.importJSConfig(configFile);
|
65 | }
|
66 | }
|
67 |
|
68 | private async findConfigFile(configFileName: unknown): Promise<string | undefined> {
|
69 | if (typeof configFileName === 'string') {
|
70 | if (await fileUtils.exists(configFileName)) {
|
71 | return configFileName;
|
72 | } else {
|
73 | throw new ConfigReaderError('File does not exist!', configFileName);
|
74 | }
|
75 | }
|
76 | for (const file of SUPPORTED_CONFIG_FILE_BASE_NAMES) {
|
77 | for (const ext of SUPPORTED_CONFIG_FILE_EXTENSIONS) {
|
78 | if (await fileUtils.exists(`${file}${ext}`)) {
|
79 | return `${file}${ext}`;
|
80 | }
|
81 | }
|
82 | }
|
83 | return undefined;
|
84 | }
|
85 |
|
86 | private async readJsonConfig(configFile: string): Promise<PartialStrykerOptions> {
|
87 | const fileContent = await fs.promises.readFile(configFile, 'utf-8');
|
88 | try {
|
89 | return JSON.parse(fileContent);
|
90 | } catch (err) {
|
91 | throw new ConfigReaderError('File contains invalid JSON', configFile, err);
|
92 | }
|
93 | }
|
94 |
|
95 | private async importJSConfig(configFile: string): Promise<PartialStrykerOptions> {
|
96 | const importedModule = await this.importJSConfigModule(configFile);
|
97 |
|
98 | if (this.hasDefaultExport(importedModule)) {
|
99 | const maybeOptions = importedModule.default;
|
100 | if (typeof maybeOptions !== 'object') {
|
101 | if (typeof maybeOptions === 'function') {
|
102 | this.log.fatal(
|
103 | `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}`
|
104 | );
|
105 | } else {
|
106 | this.log.fatal(`Invalid config file. It must export an object, found a "${typeof maybeOptions}"!\n${CONFIG_SYNTAX_HELP}`);
|
107 | }
|
108 | throw new ConfigReaderError('Default export of config file must be an object!', configFile);
|
109 | }
|
110 | if (!maybeOptions || !Object.keys(maybeOptions).length) {
|
111 | this.log.warn(`Stryker options were empty. Did you forget to export options from ${configFile}?`);
|
112 | }
|
113 |
|
114 | return { ...maybeOptions } as PartialStrykerOptions;
|
115 | } else {
|
116 | this.log.fatal(`Invalid config file. It is missing a default export. ${describeNamedExports()}\n${CONFIG_SYNTAX_HELP}`);
|
117 | throw new ConfigReaderError('Config file must have a default export!', configFile);
|
118 |
|
119 | function describeNamedExports() {
|
120 | const namedExports: string[] = (typeof importedModule === 'object' && Object.keys(importedModule ?? {})) || [];
|
121 | if (namedExports.length === 0) {
|
122 | return "In fact, it didn't export anything.";
|
123 | } else {
|
124 | return `Found named export(s): ${namedExports.map((name) => `"${name}"`).join(', ')}.`;
|
125 | }
|
126 | }
|
127 | }
|
128 | }
|
129 |
|
130 | private async importJSConfigModule(configFile: string): Promise<unknown> {
|
131 | try {
|
132 | return await fileUtils.importModule(pathToFileURL(path.resolve(configFile)).toString());
|
133 | } catch (err) {
|
134 | throw new ConfigReaderError('Error during import', configFile, err);
|
135 | }
|
136 | }
|
137 |
|
138 | private hasDefaultExport(importedModule: unknown): importedModule is { default: unknown } {
|
139 | return importedModule && typeof importedModule === 'object' && 'default' in importedModule ? true : false;
|
140 | }
|
141 | }
|
142 |
|
143 | export class ConfigReaderError extends ConfigError {
|
144 | constructor(message: string, configFileName: string, cause?: unknown) {
|
145 | super(`Invalid config file "${configFileName}". ${message}`, cause);
|
146 | }
|
147 | }
|
148 |
|
\ | No newline at end of file |