diff --git a/README.md b/README.md index 6c21634bc6..41b459a011 100644 --- a/README.md +++ b/README.md @@ -59,25 +59,28 @@ The mutators that are supported by Stryker can be found on [our website](http:// All options can be configured either via the command line or via a config file. Both `files` and `mutate` support globbing expressions using [node glob](https://github.com/isaacs/node-glob). This is the same globbing format you might know from -[Grunt](https://github.com/gruntjs/grunt) and [Karma](https://github.com/karma-runner/karma). +[Grunt](https://github.com/gruntjs/grunt) and [Karma](https://github.com/karma-runner/karma). +It is possible to *ignore* files by adding an exclamation mark `!` to the start of the expression. #### Files **Command line:** `--files node_modules/a-lib/**/*.js,src/**/*.js,a.js,test/**/*.js` or `-f node_modules/a-lib/**/*.js,src/**/*.js,a.js,test/**/*.js` -**Config file:** `files: ['test/helpers/**/*.js', 'test/unit/**/*.js', { pattern: 'src/**/*.js', included: false, mutated: true }]` +**Config file:** `files: ['{ pattern: 'src/**/*.js', mutated: true }, '!src/**/index.js', 'test/**/*.js']` **Default value:** *none* **Description:** With `files` you configure all files needed to run the tests. If the test runner you use already provides the test framework (jasmine, mocha, etc), you should not add those files here as well. The order in this list is important, because that will be the order in which the files are loaded. When using the command line, the list can only contain a comma seperated list of globbing expressions. -When using the config file you can fill an array with strings or objects: +When using the config file you can fill an array with `string`s or `InputFileDescriptor` objects: -* `string`: A globbing expression used for selecting the files needed to run the tests. -* `{ pattern: 'pattern', included: true, mutated: false }` : - * The `pattern` property is mandatory and contains the globbing expression used for selecting the files +* `string`: The globbing expression used for selecting the files needed to run the tests. +* `InputFileDescriptor` object: `{ pattern: 'pattern', included: true, mutated: false }` : + * The `pattern` property is mandatory and contains the globbing expression used for selecting the files. Using `!` to ignore files is *not* supported here. * The `included` property is optional and determines whether or not this file should be loaded initially by the test-runner (default: true) * The `mutated` property is optional and determines whether or not this file should be targeted for mutations (default: false) +*Note*: To include a file/folder which start with an exclamation mark (`!`), use the `InputFileDescriptor` syntax + #### Files to mutate **Command line:** `-m src/**/*.js,a.js` or `--mutate src/**/*.js,a.js` **Config file:** `mutate: ['src/**/*.js', 'a.js']` diff --git a/src/InputFileResolver.ts b/src/InputFileResolver.ts index f56ba69b49..42d3e9f901 100644 --- a/src/InputFileResolver.ts +++ b/src/InputFileResolver.ts @@ -4,34 +4,41 @@ import * as _ from 'lodash'; import * as log4js from 'log4js'; const log = log4js.getLogger('InputFileResolver'); + const DEFAULT_INPUT_FILE_PROPERTIES = { mutated: false, included: true }; export default class InputFileResolver { - private inputFileDescriptors: InputFileDescriptor[]; - private mutateFileExpressions: string[]; + private inputFileResolver: PatternResolver; + private mutateResolver: PatternResolver; constructor(mutate: string[], allFileExpressions: Array) { - this.mutateFileExpressions = mutate || []; - this.inputFileDescriptors = allFileExpressions.map(maybePattern => { - if (InputFileResolver.isInputFileDescriptor(maybePattern)) { - return maybePattern; - } else { - return _.assign({ pattern: maybePattern }, DEFAULT_INPUT_FILE_PROPERTIES); - } - }); + this.validateFileDescriptor(allFileExpressions); + this.mutateResolver = PatternResolver.parse(mutate || []); + this.inputFileResolver = PatternResolver.parse(allFileExpressions); } public resolve(): Promise { - let mutateFilePromise = this.resolveMutateFileGlobs(); - return this.resolveInputFileGlobs().then((allInputFiles) => mutateFilePromise.then(additionalMutateFiles => { - InputFileResolver.markAdditionalFilesToMutate(allInputFiles, additionalMutateFiles); - InputFileResolver.warnAboutNoFilesToMutate(allInputFiles); - return allInputFiles; - })); + return Promise.all([this.inputFileResolver.resolve(), this.mutateResolver.resolve()]).then(results => { + const inputFiles = results[0]; + const mutateFiles = results[1]; + this.markAdditionalFilesToMutate(inputFiles, mutateFiles.map(m => m.path)); + this.logFilesToMutate(inputFiles); + return inputFiles; + }); + } + + private validateFileDescriptor(maybeInputFileDescriptors: Array) { + maybeInputFileDescriptors.forEach(maybeInputFileDescriptor => { + if (_.isObject(maybeInputFileDescriptor)) { + if (Object.keys(maybeInputFileDescriptor).indexOf('pattern') === -1) { + throw Error(`File descriptor ${JSON.stringify(maybeInputFileDescriptor)} is missing mandatory property 'pattern'.`); + } + } + }); } - private static markAdditionalFilesToMutate(allInputFiles: InputFile[], additionalMutateFiles: string[]) { + private markAdditionalFilesToMutate(allInputFiles: InputFile[], additionalMutateFiles: string[]) { let errors: string[] = []; additionalMutateFiles.forEach(mutateFile => { if (!allInputFiles.filter(inputFile => inputFile.path === mutateFile).length) { @@ -43,57 +50,97 @@ export default class InputFileResolver { } allInputFiles.forEach(file => file.mutated = additionalMutateFiles.some(mutateFile => mutateFile === file.path) || file.mutated); } - private static warnAboutNoFilesToMutate(allInputFiles: InputFile[]) { + + private logFilesToMutate(allInputFiles: InputFile[]) { let mutateFiles = allInputFiles.filter(file => file.mutated); if (mutateFiles.length) { - log.info(`Found ${mutateFiles.length} file(s) to be mutated.`); + log.info(`Found ${mutateFiles.length} of ${allInputFiles.length} file(s) to be mutated.`); } else { log.warn(`No files marked to be mutated, stryker will perform a dry-run without actually mutating anything.`); } + if (log.isDebugEnabled) { + log.debug('All input files in order:%s', allInputFiles.map(file => '\n\t' + JSON.stringify(file))); + } } +} - private static reportEmptyGlobbingExpression(expression: string) { - log.warn(`Globbing expression "${expression}" did not result in any files.`); - } +class PatternResolver { - private static isInputFileDescriptor(maybeInputFileDescriptor: InputFileDescriptor | string): maybeInputFileDescriptor is InputFileDescriptor { - if (_.isObject(maybeInputFileDescriptor)) { - if (Object.keys(maybeInputFileDescriptor).indexOf('pattern') > -1) { - return true; - } else { - throw Error(`File descriptor ${JSON.stringify(maybeInputFileDescriptor)} is missing mandatory property 'pattern'.`); + private ignore = false; + private descriptor: InputFileDescriptor; + + constructor(descriptor: InputFileDescriptor | string, private previous?: PatternResolver) { + if (typeof descriptor === 'string') { + this.descriptor = _.assign({ pattern: descriptor }, DEFAULT_INPUT_FILE_PROPERTIES); + this.ignore = descriptor.indexOf('!') === 0; + if (this.ignore) { + this.descriptor.pattern = descriptor.substring(1); } } else { - return false; + this.descriptor = descriptor; } } - private resolveMutateFileGlobs(): Promise { - return Promise.all(this.mutateFileExpressions.map(InputFileResolver.resolveFileGlob)) - .then(files => _.flatten(files)); + resolve(): Promise { + // When the first expression starts with an '!', we skip that one + if (this.ignore && !this.previous) { + return Promise.resolve([]); + } else { + // Start the globbing task for the current descriptor + const globbingTask = this.resolveFileGlob(this.descriptor.pattern) + .then(filePaths => filePaths.map(filePath => this.createInputFile(filePath))); + if (this.previous) { + // If there is a previous globbing expression, resolve that one as well + return Promise.all([this.previous.resolve(), globbingTask]).then(results => { + const previousFiles = results[0]; + const currentFiles = results[1]; + // If this expression started with a '!', exclude current files + if (this.ignore) { + return previousFiles.filter(previousFile => currentFiles.some(currentFile => previousFile.path !== currentFile.path)); + } else { + // Only add files which were not already added + return previousFiles.concat(currentFiles.filter(currentFile => !previousFiles.some(file => file.path === currentFile.path))); + } + }); + } else { + return globbingTask; + } + } } - private resolveInputFileGlobs(): Promise { - return Promise.all( - this.inputFileDescriptors.map(descriptor => InputFileResolver.resolveFileGlob(descriptor.pattern) - .then(sourceFiles => sourceFiles.map(sourceFile => InputFileResolver.createInputFile(sourceFile, descriptor)))) - ).then(promises => _.flatten(promises)); + static empty(): PatternResolver { + const emptyResolver = new PatternResolver(''); + emptyResolver.ignore = true; + return emptyResolver; } - private static createInputFile(path: string, descriptor: InputFileDescriptor): InputFile { - let inputFile: InputFile = _.assign({ path }, DEFAULT_INPUT_FILE_PROPERTIES, descriptor); - delete (inputFile)['pattern']; - return inputFile; + static parse(inputFileExpressions: Array): PatternResolver { + const expressions = inputFileExpressions.map(i => i); // work on a copy as we're changing the array state + let current = PatternResolver.empty(); + while (expressions.length) { + current = new PatternResolver(expressions.shift(), current); + } + return current; } - - private static resolveFileGlob(expression: string): Promise { - return glob(expression).then(files => { + private resolveFileGlob(pattern: string): Promise { + return glob(pattern).then(files => { if (files.length === 0) { - this.reportEmptyGlobbingExpression(expression); + this.reportEmptyGlobbingExpression(pattern); } normalize(files); return files; }); } -} \ No newline at end of file + + private reportEmptyGlobbingExpression(expression: string) { + log.warn(`Globbing expression "${expression}" did not result in any files.`); + } + + private createInputFile(path: string): InputFile { + let inputFile: InputFile = _.assign({ path }, DEFAULT_INPUT_FILE_PROPERTIES, this.descriptor); + delete (inputFile)['pattern']; + return inputFile; + } +} + diff --git a/src/stryker-cli.ts b/src/stryker-cli.ts index 036bbd4b3d..297986abfc 100644 --- a/src/stryker-cli.ts +++ b/src/stryker-cli.ts @@ -22,7 +22,7 @@ Optional location to the stryker.conf.js file as last argument. That file should strykerConfig = config; }) .option('-f, --files ', `A comma seperated list of globbing expression used for selecting all files needed to run the tests. For a more detailed way of selecting inputfiles, please use a configFile. - Example: node_modules/a-lib/**/*.js,src/**/*.js,a.js,test/**/*.js`, list) + Example: node_modules/a-lib/**/*.js,src/**/*.js,!src/index.js,a.js,test/**/*.js`, list) .option('-m, --mutate ', `A comma seperated list of globbing expression used for selecting the files that should be mutated. Example: src/**/*.js,a.js`, list) .option('--coverageAnalysis ', `The coverage analysis strategy you want to use. Default value: "perTest"`) diff --git a/test/unit/InputFileResolverSpec.ts b/test/unit/InputFileResolverSpec.ts index fd608f7031..902833c240 100644 --- a/test/unit/InputFileResolverSpec.ts +++ b/test/unit/InputFileResolverSpec.ts @@ -1,12 +1,16 @@ import InputFileResolver from '../../src/InputFileResolver'; import * as sinon from 'sinon'; import * as fileUtils from '../../src/utils/fileUtils'; -import {InputFile} from 'stryker-api/core'; -import {expect} from 'chai'; -import {normalize, resolve} from 'path'; +import * as path from 'path'; +import { InputFile } from 'stryker-api/core'; +import { expect } from 'chai'; +import { normalize, resolve } from 'path'; import log from '../helpers/log4jsMock'; +const fileDescriptors = (paths: Array) => paths.map(p => ({ included: true, mutated: false, path: path.resolve(p) })); + describe('InputFileResolver', () => { + let sandbox: sinon.SinonSandbox; let globStub: sinon.SinonStub; let sut: InputFileResolver; @@ -16,17 +20,20 @@ describe('InputFileResolver', () => { sandbox = sinon.sandbox.create(); globStub = sandbox.stub(fileUtils, 'glob'); sandbox.stub(console, 'log'); + + globStub.withArgs('mut*tion*').resolves(['/mute1.js', '/mute2.js']); + globStub.withArgs('mutation1').resolves(['/mute1.js']); + globStub.withArgs('mutation2').resolves(['/mute2.js']); + globStub.withArgs('file1').resolves(['/file1.js']); + globStub.withArgs('file2').resolves(['/file2.js']); + globStub.withArgs('file3').resolves(['/file3.js']); + globStub.withArgs('file*').resolves(['/file1.js', '/file2.js', '/file3.js']); + globStub.resolves([]); // default }); describe('with mutant file expressions which result in files which are included in result of all globbing files', () => { beforeEach(() => { sut = new InputFileResolver(['mut*tion*'], ['file1', 'mutation1', 'file2', 'mutation2', 'file3']); - globStub.withArgs('mut*tion*').returns(Promise.resolve(['/mute1.js', '/mute2.js'])); - globStub.withArgs('mutation1').returns(Promise.resolve(['/mute1.js'])); - globStub.withArgs('mutation2').returns(Promise.resolve(['/mute2.js'])); - globStub.withArgs('file1').returns(Promise.resolve(['/file1.js'])); - globStub.withArgs('file2').returns(Promise.resolve(['/file2.js'])); - globStub.withArgs('file3').returns(Promise.resolve(['/file3.js'])); }); describe('and resolve is called', () => { @@ -59,24 +66,20 @@ describe('InputFileResolver', () => { beforeEach(() => { sut = new InputFileResolver(undefined, ['file1', { pattern: 'mutation1', included: false, mutated: true }]); - globStub.withArgs('file1').returns(Promise.resolve(['/file1.js'])); - globStub.withArgs('mutation1').returns(Promise.resolve(['/mutation1.js'])); return sut.resolve().then(r => results = r); }); it('should result in the expected input files', () => expect(results).to.deep.equal([ { included: true, mutated: false, path: resolve('/file1.js') }, - { included: false, mutated: true, path: resolve('/mutation1.js') }])); + { included: false, mutated: true, path: resolve('/mute1.js') }])); - it('should log that one file is about to be mutated', () => expect(log.info).to.have.been.calledWith('Found 1 file(s) to be mutated.')); + it('should log that one file is about to be mutated', () => expect(log.info).to.have.been.calledWith('Found 1 of 2 file(s) to be mutated.')); }); describe('without mutate property and without mutated: true in files', () => { beforeEach(() => { sut = new InputFileResolver(undefined, ['file1', { pattern: 'mutation1', included: false, mutated: false }]); - globStub.withArgs('file1').returns(Promise.resolve(['/file1.js'])); - globStub.withArgs('mutation1').returns(Promise.resolve(['/mutation1.js'])); return sut.resolve().then(r => results = r); }); @@ -88,9 +91,9 @@ describe('InputFileResolver', () => { beforeEach(() => { let resolveFile1: (result: string[]) => void; let resolveFile2: (result: string[]) => void; - sut = new InputFileResolver([], ['file1', 'file2']); - globStub.withArgs('file1').returns(new Promise(resolve => resolveFile1 = resolve)); - globStub.withArgs('file2').returns(new Promise(resolve => resolveFile2 = resolve)); + sut = new InputFileResolver([], ['fileWhichResolvesLast', 'fileWichResolvesFirst']); + globStub.withArgs('fileWhichResolvesLast').returns(new Promise(resolve => resolveFile1 = resolve)); + globStub.withArgs('fileWichResolvesFirst').returns(new Promise(resolve => resolveFile2 = resolve)); let p = sut.resolve().then(r => results = r); resolveFile2(['file2']); resolveFile1(['file1']); @@ -102,34 +105,27 @@ describe('InputFileResolver', () => { }); }); - describe('with mutant file expressions which result in files which are not included in result of all globbing files and resolve is called', () => { + describe('when selecting files to mutate which are not included', () => { let results: InputFile[]; let error: any; beforeEach(() => { sut = new InputFileResolver(['mut*tion*'], ['file1']); - globStub.withArgs('mut*tion*').returns(Promise.resolve(['/mute1.js', '/mute2.js'])); - globStub.withArgs('file1').returns(Promise.resolve(['/file1.js'])); return sut.resolve().then(r => results = r, e => error = e); }); it('should reject the result', () => { - let expectedFilesNames = ['/mute1.js', '/mute2.js']; - fileUtils.normalize(expectedFilesNames); expect(results).to.not.be.ok; expect(error.message).to.be.eq([ - `Could not find mutate file "${expectedFilesNames[0]}" in list of files.`, - `Could not find mutate file "${expectedFilesNames[1]}" in list of files.` + `Could not find mutate file "${path.resolve('/mute1.js')}" in list of files.`, + `Could not find mutate file "${path.resolve('/mute2.js')}" in list of files.` ].join(' ')); }); }); - describe('when a globbing expression does not result in a result and resolve is called', () => { - let results: InputFile[]; + describe('when a globbing expression does not result in a result', () => { beforeEach(() => { sut = new InputFileResolver(['file1'], ['file1', 'notExists']); - globStub.withArgs('notExists').returns(Promise.resolve([])); - globStub.withArgs('file1').returns(Promise.resolve(['file1.js'])); - return sut.resolve().then(r => results = r); + return sut.resolve(); }); it('should log a warning', () => { @@ -143,11 +139,9 @@ describe('InputFileResolver', () => { let expectedError: Error; beforeEach(() => { - sut = new InputFileResolver(['file1'], ['file2', 'file2']); - globStub.withArgs('notExists').returns(Promise.resolve([])); - globStub.withArgs('file1').returns(Promise.resolve(['file1.js'])); + sut = new InputFileResolver(['file1'], ['fileError', 'fileError']); expectedError = new Error('ERROR: something went wrongue'); - globStub.withArgs('file2').rejects(expectedError); + globStub.withArgs('fileError').rejects(expectedError); return sut.resolve().then(r => results = r, e => actualError = e); }); @@ -157,6 +151,31 @@ describe('InputFileResolver', () => { }); }); + describe('when excluding files with "!"', () => { + + it('should exclude the files that were previously included', () => + expect(new InputFileResolver([], ['file2', 'file1', '!file2']).resolve()).to.eventually.deep.equal(fileDescriptors(['/file1.js']))); + + it('should exclude the files that were previously with a wild card', () => + expect(new InputFileResolver([], ['file*', '!file2']).resolve()).to.eventually.deep.equal(fileDescriptors(['/file1.js', '/file3.js']))); + + it('should not exclude files added using an input file descriptor', () => + expect(new InputFileResolver([], ['file2', { pattern: '!file2' }]).resolve()).to.eventually.deep.equal(fileDescriptors(['/file2.js']))); + }); + + describe('when provided duplicate files', () => { + + it('should deduplicate files that occur more than once', () => + expect(new InputFileResolver([], ['file2', 'file2']).resolve()).to.eventually.deep.equal(fileDescriptors(['/file2.js']))); + + it('should deduplicate files that previously occured in a wildcard expression', () => + expect(new InputFileResolver([], ['file*', 'file2']).resolve()).to.eventually.deep.equal(fileDescriptors(['/file1.js', '/file2.js', '/file3.js']))); + + it('should order files by expression order', () => + expect(new InputFileResolver([], ['file2', 'file*']).resolve()).to.eventually.deep.equal(fileDescriptors(['/file2.js', '/file1.js', '/file3.js']))); + + }); + afterEach(() => { sandbox.restore(); }); diff --git a/testResources/sampleProject/stryker.conf.js b/testResources/sampleProject/stryker.conf.js index 4b9c6c3c14..8629480922 100644 --- a/testResources/sampleProject/stryker.conf.js +++ b/testResources/sampleProject/stryker.conf.js @@ -1,6 +1,11 @@ module.exports = function (config) { config.set({ - files: [{ pattern: 'testResources/sampleProject/src/?(Circle|Add).js', mutated: true }, 'testResources/sampleProject/test/?(AddSpec|CircleSpec).js'], + files: [ + { pattern: 'testResources/sampleProject/src/*.js', mutated: true }, + 'testResources/sampleProject/test/*.js', + '!testResources/sampleProject/src/Error.js', + '!testResources/sampleProject/src/InfiniteAdd.js', + '!testResources/sampleProject/test/FailingAddSpec.js',], testFramework: 'jasmine', testRunner: 'karma', coverageAnalysis: 'off',