UNPKG

recink-coverage

Version:

REciNK Component for Tests Coverage

438 lines (365 loc) 11.4 kB
'use strict'; /* eslint no-useless-call: 0 */ const DependantConfigBasedComponent = require('./dependant-config-based-component'); const istanbul = require('istanbul'); const testEvents = require('./test/events'); const events = require('./coverage/events'); const ContainerTransformer = require('./helper/container-transformer'); const path = require('path'); const fs = require('fs'); const pify = require('pify'); const StorageFactory = require('./coverage/factory'); const ModuleCompile = require('./helper/module-compile'); /** * Coverage component */ class CoverageComponent extends DependantConfigBasedComponent { /** * @param {*} args */ constructor(...args) { super(...args); this._storage = null; } /** * @returns {string} */ get name() { return 'coverage'; } /** * @returns {string[]} */ get dependencies() { return [ 'test' ]; } /** * @returns {AbstractDriver} * * @private */ get _comparatorStorage() { if (this._storage) { return this._storage; } const driver = this.container.get('compare.storage.driver', 'volatile'); const options = this.container.get('compare.storage.options', []); this._storage = StorageFactory.create(driver, ...options); return this._storage; } /** * @param {istanbul.Collector} collector * * @returns {Promise} * * @private */ _doCompare(collector) { const allowedDelta = Math.abs(parseFloat( this.container.get('compare.negative-delta', 100) )); if (!allowedDelta || allowedDelta >= 100) { return Promise.resolve(); } const storage = this._comparatorStorage; if (storage.constructor.name === 'VolatileDriver') { this.logger.debug( `Coverage stored in ${ storage._storageFile(CoverageComponent.COVERAGE_FILE) }` ); } return storage.read(CoverageComponent.COVERAGE_FILE) .then(coverage => { const newCoverage = collector.getFinalCoverage(); if (!coverage) { this.logger.info( this.logger.emoji.bicycle, 'No previous coverage info saved...' ); return storage.write( CoverageComponent.COVERAGE_FILE, newCoverage ); } const delta = this._calculateDelta(coverage, newCoverage); if (delta > allowedDelta) { return Promise.reject(new Error( `Coverage delta decreased ${ delta.toFixed(2) } > ${ allowedDelta.toFixed(2) }` )); } this.logger.info( this.logger.emoji.bicycle, `Coverage delta checked ${ delta.toFixed(2) } >= ${ allowedDelta.toFixed(2) }` ); return storage.write( CoverageComponent.COVERAGE_FILE, newCoverage ); }); } /** * @param {*} coverage * @param {*} newCoverage * * @returns {number} * * @private */ _calculateDelta(coverage, newCoverage) { const summary = this._summarizeCoverage(coverage); const newSummary = this._summarizeCoverage(newCoverage); return CoverageComponent.COVERAGE_KEYS .reduce((accumulator, key) => { return accumulator + parseFloat(summary[key]) - parseFloat((newSummary[key] || 0)); }, 0) / CoverageComponent.COVERAGE_KEYS.length; } /** * @param {*} coverage * * @returns {*} * * @private */ _summarizeCoverage(coverage) { const summary = {}; const summaries = Object.keys(coverage) .map(file => { return istanbul.utils .summarizeFileCoverage(coverage[file]); }); const summaryObj = istanbul.utils .mergeSummaryObjects .apply(null, summaries); Object.keys(summaryObj) .map(key => { summary[key] = summaryObj[key].pct; }); return summary; } /** * @param {*} assetsToInstrument * @param {*} dispatchedAssets * @param {EmitModule} module * * @returns {Promise} * * @private */ _persistModuleBlankCoverage(assetsToInstrument, dispatchedAssets, module) { if (!assetsToInstrument[module.name]) { return Promise.resolve(); } const coverageVariable = this._coverageVariable(module); return Promise.all( assetsToInstrument[module.name] .filter(asset => { return (dispatchedAssets[module.name] || []).indexOf(asset) === -1; }) .map(asset => { return pify(fs.readFile)(asset) .then(content => { const instrumenter = new istanbul.Instrumenter({ coverageVariable }); instrumenter.instrumentSync( content.toString(), asset ); global[coverageVariable] = global[coverageVariable] || {}; global[coverageVariable][asset] = instrumenter.lastFileCoverage(); return Promise.resolve(); }); }) ); } /** * @param {Emitter} emitter * * @returns {Promise} * ' * @todo split into several abstractions */ run(emitter) { return new Promise((resolve, reject) => { const collector = new istanbul.Collector(); const reporter = new istanbul.Reporter(); const reporters = this.container.get('reporters', {}); const coverageVariables = []; const assetsToInstrument = {}; const dispatchedAssets = {}; Object.keys(reporters).map(reporterName => { const report = istanbul.Report.create( reporterName, reporters[reporterName] || {} ); reporter.reports[reporterName] = report; }); emitter.onBlocking(testEvents.asset.test.skip, payload => { if (!this._match(payload.file)) { return Promise.resolve(); } const { module, fileAbs } = payload; assetsToInstrument[module.name] = assetsToInstrument[module.name] || []; assetsToInstrument[module.name].push(fileAbs); return Promise.resolve(); }); emitter.onBlocking(testEvents.asset.tests.end, (mocha, module) => { return this._persistModuleBlankCoverage( assetsToInstrument, dispatchedAssets, module ).then(() => { delete assetsToInstrument[module.name]; delete dispatchedAssets[module.name]; return Promise.resolve(); }); }); emitter.onBlocking(testEvents.asset.tests.start, (mocha, module) => { return new Promise(resolve => { const instrumenterCache = {}; const coverageVariable = this._coverageVariable(module); const instrumenter = new istanbul.Instrumenter({ coverageVariable }); coverageVariables.push(coverageVariable); if (mocha) { mocha.loadFiles = (fn => { const self = this; const moduleRoot = module.container.get('root'); const coverableAssets = assetsToInstrument[module.name] || []; const preprocessor = (source, filename) => { if (coverableAssets.indexOf(filename) !== -1 && self._match(path.relative(moduleRoot, filename))) { if (instrumenterCache.hasOwnProperty(filename)) { return instrumenterCache[filename]; } instrumenterCache[filename] = instrumenter.instrumentSync.call(instrumenter, source, filename); dispatchedAssets[module.name] = dispatchedAssets[module.name] || []; dispatchedAssets[module.name].push(filename); return instrumenterCache[filename]; } return source; }; mocha.files.map(file => { file = path.resolve(file); mocha.suite.emit('pre-require', global, file, mocha); mocha.suite.emit( 'require', ModuleCompile.require(file, {}, preprocessor), file, mocha ); mocha.suite.emit('post-require', global, file, mocha); }); fn && fn(); }); } resolve(); }); }); emitter.onBlocking(testEvents.assets.test.end, () => { coverageVariables.map(coverageVariable => { collector.add(global[coverageVariable] || {}); }); return emitter.emitBlocking( events.coverage.report.create, istanbul, reporter, collector ) .then(() => this._dumpCoverageStats(collector, reporter)) .then(() => { return emitter.emitBlocking( events.coverage.report.compare, istanbul, reporter, collector ); }) .then(() => this._doCompare(collector)); }); emitter.on(testEvents.assets.test.end, () => resolve()); }); } /** * @param {istanbul.Collector} collector * @param {istanbul.Reporter} reporter * * @returns {Promise} * * @private */ _dumpCoverageStats(collector, reporter) { return new Promise(resolve => { reporter.write(collector, false, () => { // @todo find a smarter way to indent the output (buffer it?) process.stdout.write('\n\n'); resolve(); }); }); } /** * @param {EmitModule} module * * @returns {string} * * @private */ _coverageVariable(module) { const cleanModuleName = module.name.replace(/[^a-z0-9]/g, '_'); return `__recink_coverage__${ cleanModuleName }__`; } /** * @param {string} file * * @returns {boolean} * * @private */ _match(file) { const pattern = this.container.get('pattern', []); const ignore = this.container.get('ignore', []); const result = pattern.filter(p => this._test(p, file)).length > 0 && ignore.filter(i => this._test(i, file)).length <= 0; return result; } /** * @param {string|RegExp} pattern * @param {string} value * * @returns {boolean} * * @private */ _test(pattern, value) { if (!(pattern instanceof RegExp)) { return value.indexOf(pattern.toString()) !== -1; } return pattern.test(value); } /** * @param {*} config * @param {string} configFile * * @returns {Container} */ prepareConfig(config, configFile) { return super.prepareConfig(config, configFile) .then(container => { return (new ContainerTransformer(container)) .addPattern('pattern') .addPattern('ignore') .transform(); }); } /** * @returns {Array} */ static get COVERAGE_KEYS() { return [ 'lines', 'statements', 'functions', 'branches' ]; } /** * @returns {string} */ static get COVERAGE_FILE() { return 'coverage.json'; } } module.exports = CoverageComponent;