@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
474 lines • 24 kB
JavaScript
import childProcess from 'child_process';
import fs from 'fs';
import { syncBuiltinESMExports } from 'module';
import { testInjector } from '@stryker-mutator/test-helpers';
import { childProcessAsPromised, normalizeWhitespaces } from '@stryker-mutator/util';
import { expect } from 'chai';
import inquirer from 'inquirer';
import sinon from 'sinon';
import typedRestClient from 'typed-rest-client/RestClient.js';
import { fileUtils } from '../../../src/utils/file-utils.js';
import { initializerTokens } from '../../../src/initializer/index.js';
import { NpmClient } from '../../../src/initializer/npm-client.js';
import { StrykerConfigWriter } from '../../../src/initializer/stryker-config-writer.js';
import { StrykerInitializer } from '../../../src/initializer/stryker-initializer.js';
import { StrykerInquirer } from '../../../src/initializer/stryker-inquirer.js';
import { GitignoreWriter } from '../../../src/initializer/gitignore-writer.js';
import { SUPPORTED_CONFIG_FILE_NAMES } from '../../../src/config/config-file-formats.js';
describe(StrykerInitializer.name, () => {
let sut;
let inquirerPrompt;
let childExecSync;
let childExec;
let fsWriteFile;
let existsStub;
let npmRestClient;
let gitignoreWriter;
let out;
let customInitializers;
let customInitializerMock;
beforeEach(() => {
out = sinon.stub();
customInitializers = [];
customInitializerMock = {
createConfig: sinon.stub(),
name: 'awesome-preset',
};
childExec = sinon.stub(childProcessAsPromised, 'exec');
inquirerPrompt = sinon.stub(inquirer, 'prompt');
childExecSync = sinon.stub(childProcess, 'execSync');
fsWriteFile = sinon.stub(fs.promises, 'writeFile');
existsStub = sinon.stub(fileUtils, 'exists');
npmRestClient = sinon.createStubInstance(typedRestClient.RestClient);
gitignoreWriter = sinon.createStubInstance(GitignoreWriter);
syncBuiltinESMExports();
sut = testInjector.injector
.provideValue(initializerTokens.out, out)
.provideValue(initializerTokens.restClientNpm, npmRestClient)
.provideClass(initializerTokens.inquirer, StrykerInquirer)
.provideClass(initializerTokens.npmClient, NpmClient)
.provideValue(initializerTokens.customInitializers, customInitializers)
.provideClass(initializerTokens.configWriter, StrykerConfigWriter)
.provideValue(initializerTokens.gitignoreWriter, gitignoreWriter)
.injectClass(StrykerInitializer);
});
describe('initialize()', () => {
beforeEach(() => {
stubTestRunners('@stryker-mutator/awesome-runner', 'stryker-hyper-runner', 'stryker-ghost-runner', '@stryker-mutator/jest-runner');
stubReporters('stryker-dimension-reporter', '@stryker-mutator/mars-reporter');
stubPackageClient({
'@stryker-mutator/awesome-runner': undefined,
'@stryker-mutator/javascript-mutator': undefined,
'@stryker-mutator/mars-reporter': undefined,
'@stryker-mutator/typescript': undefined,
'@stryker-mutator/webpack': undefined,
'stryker-dimension-reporter': undefined,
'stryker-ghost-runner': undefined,
'stryker-hyper-runner': {
files: [],
someOtherSetting: 'enabled',
},
'@stryker-mutator/jest-runner': undefined,
});
fsWriteFile.resolves();
customInitializers.push(customInitializerMock);
});
it('should prompt for preset, test runner, reporters, package manager and config type', async () => {
arrangeAnswers({
packageManager: 'yarn',
reporters: ['dimension', 'mars'],
testRunner: 'awesome',
});
await sut.initialize();
expect(inquirerPrompt).callCount(6);
const [promptPreset, promptTestRunner, promptBuildCommand, promptReporters, promptPackageManagers, promptConfigTypes,] = [
inquirerPrompt.getCall(0).args[0],
inquirerPrompt.getCall(1).args[0],
inquirerPrompt.getCall(2).args[0],
inquirerPrompt.getCall(3).args[0],
inquirerPrompt.getCall(4).args[0],
inquirerPrompt.getCall(5).args[0],
];
expect(promptPreset.type).to.eq('list');
expect(promptPreset.name).to.eq('preset');
expect(promptPreset.choices).to.deep.eq(['awesome-preset', new inquirer.Separator(), 'None/other']);
expect(promptTestRunner.type).to.eq('list');
expect(promptTestRunner.name).to.eq('testRunner');
expect(promptTestRunner.choices).to.deep.eq(['awesome', 'hyper', 'ghost', 'jest', new inquirer.Separator(), 'command']);
expect(promptBuildCommand.name).to.eq('buildCommand');
expect(promptReporters.type).to.eq('checkbox');
expect(promptReporters.choices).to.deep.eq(['dimension', 'mars', 'html', 'clear-text', 'progress', 'dashboard']);
expect(promptPackageManagers.type).to.eq('list');
expect(promptPackageManagers.choices).to.deep.eq(['npm', 'yarn', 'pnpm']);
expect(promptConfigTypes.type).to.eq('list');
expect(promptConfigTypes.choices).to.deep.eq(['JSON', 'JavaScript']);
});
it('should immediately complete when a preset and package manager is chosen', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
preset: 'awesome-preset',
configType: 'JSON',
});
resolvePresetConfig();
await sut.initialize();
expect(inquirerPrompt).callCount(3);
expect(out).calledWith('Done configuring stryker. Please review "stryker.config.json", you might need to configure your test runner correctly.');
expect(out).calledWith("Let's kill some mutants with this command: `stryker run`");
});
it('should correctly write and format the stryker js configuration file', async () => {
const guideUrl = 'https://awesome-preset.org';
const config = { awesomeConf: 'awesome' };
childExec.resolves();
resolvePresetConfig({
config,
guideUrl,
});
const expectedOutput = `// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
"_comment": "This config was generated using 'stryker init'. Please see the guide for more information: https://awesome-preset.org",
"awesomeConf": "${config.awesomeConf}"
};
export default config;`;
inquirerPrompt.resolves({
packageManager: 'npm',
preset: 'awesome-preset',
configType: 'JavaScript',
});
await sut.initialize();
expectStrykerConfWritten(expectedOutput);
expect(childExec).calledWith('npx prettier --write stryker.config.mjs');
});
it('should handle errors when formatting fails', async () => {
// Arrange
const expectedError = new Error('Formatting fails');
childExec.rejects(expectedError);
inquirerPrompt.resolves({
packageManager: 'npm',
preset: 'awesome-preset',
configType: 'JavaScript',
});
resolvePresetConfig();
// Act
await sut.initialize();
// Assert
expect(out).calledWith('Unable to format stryker.config.mjs file for you. This is not a big problem, but it might look a bit messy 🙈.');
expect(testInjector.logger.debug).calledWith('Prettier exited with error', expectedError);
});
it('should correctly load dependencies from the preset', async () => {
resolvePresetConfig({ dependencies: ['my-awesome-dependency', 'another-awesome-dependency'] });
inquirerPrompt.resolves({
packageManager: 'npm',
preset: 'awesome-preset',
configType: 'JSON',
});
await sut.initialize();
expect(fsWriteFile).calledOnce;
expect(childExecSync).calledWith('npm i --save-dev my-awesome-dependency another-awesome-dependency', { stdio: [0, 1, 2] });
});
it('should correctly load configuration from a preset', async () => {
resolvePresetConfig();
inquirerPrompt.resolves({
packageManager: 'npm',
preset: 'awesome-preset',
configType: 'JSON',
});
await sut.initialize();
expect(inquirerPrompt).callCount(3);
const [promptPreset, promptConfigType, promptPackageManager] = [
inquirerPrompt.getCall(0).args[0],
inquirerPrompt.getCall(1).args[0],
inquirerPrompt.getCall(2).args[0],
];
expect(promptPreset.type).to.eq('list');
expect(promptPreset.name).to.eq('preset');
expect(promptPreset.choices).to.deep.eq(['awesome-preset', new inquirer.Separator(), 'None/other']);
expect(promptConfigType.type).to.eq('list');
expect(promptConfigType.choices).to.deep.eq(['JSON', 'JavaScript']);
expect(promptPackageManager.type).to.eq('list');
expect(promptPackageManager.choices).to.deep.eq(['npm', 'yarn', 'pnpm']);
});
it('should install any additional dependencies', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: ['dimension', 'mars'],
testRunner: 'awesome',
configType: 'JSON',
});
await sut.initialize();
expect(out).calledWith('Installing NPM dependencies...');
expect(childExecSync).calledWith('npm i --save-dev @stryker-mutator/awesome-runner stryker-dimension-reporter @stryker-mutator/mars-reporter', {
stdio: [0, 1, 2],
});
});
it('should install additional dependencies with pnpm', async () => {
inquirerPrompt.resolves({
packageManager: 'pnpm',
reporters: [],
testRunner: 'awesome',
});
await sut.initialize();
expect(childExecSync).calledWith('pnpm add -D @stryker-mutator/awesome-runner', {
stdio: [0, 1, 2],
});
});
it('should explicitly specify plugins when using pnpm', async () => {
childExec.resolves();
const expectedOutput = `// @ts-check
/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
const config = {
"_comment": "This config was generated using 'stryker init'. Please take a look at: https://stryker-mutator.io/docs/stryker-js/configuration/ for more information.",
"packageManager": "pnpm",
"reporters": [],
"testRunner": "awesome",
"testRunner_comment": "Take a look at (missing 'homepage' URL in package.json) for information about the awesome plugin.",
"coverageAnalysis": "perTest",
"plugins": [ "@stryker-mutator/awesome-runner" ]
};
export default config;`;
inquirerPrompt.resolves({
packageManager: 'pnpm',
reporters: [],
testRunner: 'awesome',
configType: 'JavaScript',
});
await sut.initialize();
expectStrykerConfWritten(expectedOutput);
});
it('should configure testRunner, reporters, and packageManager', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: ['dimension', 'mars', 'progress'],
testRunner: 'awesome',
configType: 'JSON',
});
await sut.initialize();
expect(fsWriteFile).calledOnce;
const [fileName, content] = fsWriteFile.getCall(0).args;
expect(fileName).eq('stryker.config.json');
const normalizedContent = normalizeWhitespaces(content);
expect(normalizedContent).contains('"testRunner": "awesome"');
expect(normalizedContent).contains('"packageManager": "npm"');
expect(normalizedContent).contains('"coverageAnalysis": "perTest"');
expect(normalizedContent).contains('"dimension", "mars", "progress"');
});
it('should configure the additional settings from the plugins', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: [],
testRunner: 'hyper',
configType: 'JSON',
});
await sut.initialize();
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match('"someOtherSetting": "enabled"'));
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match('"files": []'));
});
it('should annotate the config file with the docs url', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: [],
testRunner: 'hyper',
configType: 'JSON',
});
await sut.initialize();
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match('"_comment": "This config was generated using \'stryker init\'. Please take a look at: https://stryker-mutator.io/docs/stryker-js/configuration/ for more information."'));
});
it('should not prompt for buildCommand if test runner is jest', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: ['dimension', 'mars', 'progress'],
testRunner: 'jest',
configType: 'JSON',
buildCommand: 'none',
});
await sut.initialize();
const promptBuildCommand = inquirerPrompt.getCalls().filter((call) => call.args[0].name === 'buildCommand');
expect(promptBuildCommand.length === 1);
expect(promptBuildCommand[0].args[0].when).to.be.false;
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match((val) => !val.includes('"buildCommand": ')));
});
it('should not write "buildCommand" config option if empty buildCommand entered', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: [],
testRunner: 'hyper',
configType: 'JSON',
buildCommand: 'none',
});
await sut.initialize();
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match((val) => !val.includes('"buildCommand": ')));
});
it('should save entered build command', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: [],
testRunner: 'hyper',
configType: 'JSON',
buildCommand: 'npm run build',
});
await sut.initialize();
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match('"buildCommand": "npm run build"'));
});
it('should set "coverageAnalysis" to "off" when the command test runner is chosen', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: [],
testRunner: 'command',
configType: 'JSON',
});
await sut.initialize();
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match('"coverageAnalysis": "off"'));
});
it('should reject with that error', () => {
const expectedError = new Error('something');
fsWriteFile.rejects(expectedError);
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: [],
testRunner: 'ghost',
configType: 'JSON',
});
return expect(sut.initialize()).to.eventually.be.rejectedWith(expectedError);
});
it('should recover when install fails', async () => {
childExecSync.throws('error');
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: [],
testRunner: 'ghost',
configType: 'JSON',
});
await sut.initialize();
expect(out).calledWith('An error occurred during installation, please try it yourself: "npm i --save-dev stryker-ghost-runner"');
expect(fs.promises.writeFile).called;
});
it('should write not found if test runner homepage url as comment when not found', async () => {
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: [],
testRunner: 'hyper',
configType: 'JSON',
});
await sut.initialize();
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match('"testRunner_comment": "Take a look at (missing \'homepage\' URL in package.json) for information about the hyper plugin."'));
});
it('should write URL if test runner homepage url as comment', async () => {
stubPackageClient({ 'stryker-hyper-runner': { name: 'hyper' } }, 'https://url-to-hyper.com');
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: [],
testRunner: 'hyper',
configType: 'JSON',
});
await sut.initialize();
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match('"testRunner_comment": "Take a look at https://url-to-hyper.com for information about the hyper plugin."'));
});
});
describe('initialize() when no internet', () => {
it('should log error and continue when fetching test runners', async () => {
npmRestClient.get.withArgs('/-/v1/search?text=keywords:%40stryker-mutator%2Ftest-runner-plugin').rejects();
stubReporters();
stubPackageClient({ 'stryker-javascript': undefined, 'stryker-webpack': undefined });
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: ['clear-text'],
configType: 'JSON',
});
await sut.initialize();
expect(testInjector.logger.error).calledWith("Unable to reach 'https://registry.npmjs.com' (for query /-/v1/search?text=keywords:%40stryker-mutator%2Ftest-runner-plugin). Please check your internet connection.");
expect(fs.promises.writeFile).calledWith('stryker.config.json', sinon.match('"testRunner": "command"'));
});
it('should log error and continue when fetching stryker reporters', async () => {
stubTestRunners('stryker-awesome-runner');
npmRestClient.get.withArgs('/-/v1/search?text=keywords:%40stryker-mutator%2Freporter-plugin').rejects();
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: ['clear-text'],
testRunner: 'awesome',
configType: 'JSON',
});
stubPackageClient({ 'stryker-awesome-runner': undefined, 'stryker-javascript': undefined, 'stryker-webpack': undefined });
await sut.initialize();
expect(testInjector.logger.error).calledWith("Unable to reach 'https://registry.npmjs.com' (for query /-/v1/search?text=keywords:%40stryker-mutator%2Freporter-plugin). Please check your internet connection.");
expect(fs.promises.writeFile).called;
});
it('should log warning and continue when fetching custom config', async () => {
stubTestRunners('stryker-awesome-runner');
stubReporters();
inquirerPrompt.resolves({
packageManager: 'npm',
reporters: ['clear-text'],
testRunner: 'awesome',
configType: 'JSON',
});
npmRestClient.get.rejects();
await sut.initialize();
expect(testInjector.logger.warn).calledWith('Could not fetch additional initialization config for dependency stryker-awesome-runner. You might need to configure it manually');
expect(fs.promises.writeFile).called;
});
});
SUPPORTED_CONFIG_FILE_NAMES.forEach((fileName) => {
it(`should log an error and quit when \`${fileName}\` file already exists`, async () => {
existsStub.withArgs(fileName).resolves(true);
await expect(sut.initialize()).to.be.rejected;
expect(testInjector.logger.error).calledWith(`Stryker config file "${fileName}" already exists in the current directory. Please remove it and try again.`);
});
});
const stubTestRunners = (...testRunners) => {
const testRunnersResult = {
total: testRunners.length,
objects: testRunners.map((testRunner) => ({
package: { name: testRunner, version: '1.1.1', keywords: ['@stryker-mutator/test-runner-plugin'] },
})),
};
npmRestClient.get.withArgs('/-/v1/search?text=keywords:%40stryker-mutator%2Ftest-runner-plugin').resolves({
result: testRunnersResult,
statusCode: 200,
headers: {},
});
};
const stubReporters = (...reporters) => {
const reportersResult = {
total: reporters.length,
objects: reporters.map((reporter) => ({ package: { name: reporter, version: '1.1.1', keywords: ['@stryker-mutator/reporter-plugin'] } })),
};
npmRestClient.get
.withArgs('/-/v1/search?text=keywords:%40stryker-mutator%2Freporter-plugin')
.resolves({ statusCode: 200, headers: {}, result: reportersResult });
};
const stubPackageClient = (initStrykerConfigPerPackage, homepage) => {
Object.keys(initStrykerConfigPerPackage).forEach((name) => {
const initStrykerConfig = initStrykerConfigPerPackage[name];
const result = {
name,
homepage,
initStrykerConfig,
keywords: [],
version: '1.1.1',
};
npmRestClient.get.withArgs(`/${encodeURIComponent(name)}@1.1.1`).resolves({
result,
statusCode: 200,
headers: {},
});
});
};
function arrangeAnswers(answerOverrides) {
const answers = Object.assign({
packageManager: 'yarn',
preset: null,
reporters: ['dimension', 'mars'],
testRunner: 'awesome',
}, answerOverrides);
inquirerPrompt.resolves(answers);
}
function resolvePresetConfig(overrides) {
customInitializerMock.createConfig.resolves({ config: {}, dependencies: [], guideUrl: '', ...overrides });
}
function expectStrykerConfWritten(expectedRawConfig) {
const [fileName, actualConfig] = fsWriteFile.getCall(0).args;
expect(fileName).eq('stryker.config.mjs');
expect(normalizeWhitespaces(actualConfig)).eq(normalizeWhitespaces(expectedRawConfig));
}
});
//# sourceMappingURL=stryker-initializer.spec.js.map