Skip to content

Commit

Permalink
feat(esm config): support config file as pure esm (#3432)
Browse files Browse the repository at this point in the history
Support your `stryker.conf.js` file to be a pure ECMAScript Module (ESM). Either using the `.mjs` extension, or `.js` if you use `{ "type": module" }` in your `package.json`. 

Example:

```js
// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
  // Your config here!
};
export default config;
```

More changes:
* Support `stryker.conf.cjs` for completeness sake. That way you can keep using `cjs` for your stryker config if you prefer.
* Remove the workaround for exporting a `function` from your `stryker.conf.js` file.

BREAKING CHANGE: Exporting a function (using `module.exports = function(config) {}`) from your `stryker.conf.js` file is no longer supported. This was already deprecated but now will give an error.
  • Loading branch information
nicojs authored Feb 17, 2022
1 parent 78c305e commit 309a7e2
Show file tree
Hide file tree
Showing 54 changed files with 681 additions and 283 deletions.
16 changes: 14 additions & 2 deletions docs/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,26 @@ With a `stryker.conf.json`:
Or as `stryker.conf.js`:

```js
// @ts-check
/**
* @type {import('@stryker-mutator/api/core').StrykerOptions}
* @type {import('@stryker-mutator/api/core').PartialStrykerOptions}
*/
module.exports = {
// Your config here
};
```

Since Stryker version 6 you can define your config in a native [ECMAScript module](https://nodejs.org/api/esm.html). Either using the `.mjs` extension, or `.js` if you use `{ "type": module" }` in your `package.json`.

```js
// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
// Your config here
};
export default;
```

You can use your editor's autocompletion to help you author your configuration file.

![config file autocompletion](./images/config-file-autocompletion.gif)
Expand All @@ -40,7 +52,7 @@ You can use your editor's autocompletion to help you author your configuration f
By default, Stryker will look for a "stryker.conf.js" or "stryker.conf.json" file in the current working directory (cwd). You can also use a different configuration file with a second argument to the `run` command.

```shell
# Use "stryker.conf.js" or "stryker.conf.json" in the cwd
# Use "stryker.conf.[js,json,mjs]" in the cwd
npx stryker run
# Use "alternative-stryker.conf.json"
npx stryker run alternative-stryker.conf.json
Expand Down
1 change: 1 addition & 0 deletions e2e/test/jest-react-ts/stryker.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
],
"concurrency": 2,
"coverageAnalysis": "perTest",
"timeoutMS": 10000,
"reporters": [
"json",
"progress",
Expand Down
4 changes: 2 additions & 2 deletions e2e/test/karma-mocha/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"scripts": {
"pretest": "rimraf \"reports\"",
"test": "npm run test:not-in-place && npm run test:in-place",
"test:not-in-place": "stryker run stryker.conf.js",
"test:not-in-place": "stryker run",
"posttest:not-in-place": "npm run verify",
"test:in-place": "stryker run stryker.conf.js --inPlace",
"test:in-place": "stryker run --inPlace",
"posttest:in-place": "npm run verify",
"verify": "mocha --no-config --no-package --timeout 0 verify/verify.js"
},
Expand Down
17 changes: 0 additions & 17 deletions e2e/test/karma-mocha/stryker.conf.js

This file was deleted.

15 changes: 15 additions & 0 deletions e2e/test/karma-mocha/stryker.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"mutate": ["src/*.js"],
"testRunner": "karma",
"reporters": ["json", "clear-text", "html", "event-recorder"],
"karma": {
"config": {
"frameworks": ["mocha", "chai"],
"files": ["src/*.js", "test/*.js"]
}
},
"timeoutMS": 60000,
"concurrency": 2,
"coverageAnalysis": "perTest",
"plugins": ["@stryker-mutator/karma-runner"]
}
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
}
},
"scripts": {
"stryker": "node ../core/bin/stryker.js run stryker.conf.cjs",
"stryker": "node ../core/bin/stryker.js run",
"test": "c8 npm run test:unit",
"test:unit": "mocha \"dist/test/unit/**/*.js\""
},
Expand Down
7 changes: 0 additions & 7 deletions packages/api/stryker.conf.cjs

This file was deleted.

11 changes: 11 additions & 0 deletions packages/api/stryker.conf.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* eslint-disable import/no-default-export */
// @ts-check
import fs from 'fs';
import { URL } from 'url';

const settings = JSON.parse(fs.readFileSync(new URL('../../stryker.parent.conf.json', import.meta.url), 'utf-8'));
settings.dashboard.module = import.meta.url.split('/').slice(-2)[0];
/**
* @type {import('../api/dist/src/core/index.js').PartialStrykerOptions}
*/
export default settings;
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"test:all": "npm run test:unit && npm run test:integration",
"test:unit": "mocha 'dist/test/unit/**/*.js'",
"test:integration": "mocha --timeout 60000 'dist/test/integration/**/*.js'",
"stryker": "node bin/stryker.js run stryker.conf.cjs"
"stryker": "node bin/stryker.js run"
},
"repository": {
"type": "git",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/config/config-file-formats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const DEFAULT_CONFIG_FILE_BASE_NAME = 'stryker.conf';
export const SUPPORTED_CONFIG_FILE_EXTENSIONS = Object.freeze(['.json', '.js', '.mjs', '.cjs'] as const);
156 changes: 104 additions & 52 deletions packages/core/src/config/config-reader.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,146 @@
import fs from 'fs';
import path from 'path';
import { createRequire } from 'module';
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 } from '@stryker-mutator/util';
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 { defaultOptions, OptionsValidator } from './options-validator.js';
import { createConfig } from './create-config.js';
import { OptionsValidator } from './options-validator.js';
import { DEFAULT_CONFIG_FILE_BASE_NAME, 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!
}`.trim();
}
const DEFAULT_CONFIG_FILE = 'stryker.conf';
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: OptionsValidator) {}
constructor(private readonly log: Logger, private readonly validator: I<OptionsValidator>) {}

public async readConfig(cliOptions: PartialStrykerOptions): Promise<StrykerOptions> {
const configModule = this.loadConfigModule(cliOptions);
let options: StrykerOptions;
if (typeof configModule === 'function') {
this.log.warn(
'Usage of `module.exports = function(config) {}` is deprecated. Please use `module.export = {}` or a "stryker.conf.json" file. For more details, see https://stryker-mutator.io/blog/2020-03-11/stryker-version-3#new-config-format'
);
options = defaultOptions();
configModule(createConfig(options));
} else {
this.validator.validate(configModule);
options = configModule;
}
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 loadConfigModule(cliOptions: PartialStrykerOptions): PartialStrykerOptions | ((options: StrykerOptions) => void) {
let configModule: PartialStrykerOptions | ((config: StrykerOptions) => void) = {};
const require = createRequire(import.meta.url);

if (!cliOptions.configFile) {
try {
const configFile = require.resolve(path.resolve(`./${DEFAULT_CONFIG_FILE}`));
this.log.info(`Using ${path.basename(configFile)}`);
cliOptions.configFile = configFile;
} catch (e) {
this.log.info('No config file specified. Running with command line arguments.');
this.log.info('Use `stryker init` command to generate your config file.');
}
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);
}
}

if (typeof cliOptions.configFile === 'string') {
this.log.debug(`Loading config ${cliOptions.configFile}`);
const configFile = this.resolveConfigFile(cliOptions.configFile);
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
configModule = require(configFile);
} catch (error: unknown) {
this.log.info('Stryker can help you setup a `stryker.conf` file for your project.');
this.log.info("Please execute `stryker init` in your project's root directory.");
throw new ConfigError('Invalid config file', error);
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);
}
if (typeof configModule !== 'function' && typeof configModule !== 'object') {
this.log.fatal('Config file must export an object!\n' + CONFIG_SYNTAX_HELP);
throw new ConfigError('Config file must export an object!');
}
const candidates = SUPPORTED_CONFIG_FILE_EXTENSIONS.map((ext) => `${DEFAULT_CONFIG_FILE_BASE_NAME}${ext}`);
for (const candidate of candidates) {
if (await fileUtils.exists(candidate)) {
return candidate;
}
}
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);

return configModule;
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 resolveConfigFile(configFileName: string) {
const configFile = path.resolve(configFileName);
private async importJSConfigModule(configFile: string): Promise<unknown> {
try {
const require = createRequire(import.meta.url);
return require.resolve(configFile);
} catch {
throw new ConfigError(`File ${configFile} does not exist!`);
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);
}
}
15 changes: 0 additions & 15 deletions packages/core/src/config/create-config.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './options-validator.js';
export * from './file-matcher.js';
export * from './meta-schema-builder.js';
export * from './config-reader.js';
export * from './config-file-formats.js';
Loading

0 comments on commit 309a7e2

Please sign in to comment.