From 640985c544d816b8ab7f3a768e92efd34a698183 Mon Sep 17 00:00:00 2001 From: Sergey Tatarintsev Date: Tue, 4 Nov 2014 16:31:59 +0300 Subject: [PATCH] Support reporting coverage for tests If `coverage` options is set, template files will be instrumented before execution (can be enabled per-technology). If templates are built with source map, there is an option to show coverage for original files. There is also an option to filter out some sources from report. By default, all content of `node_modules` and `libs` will be omitted. --- lib/coverage-object.js | 81 ++++++++++++++++++++++++++++++++++++++++ lib/node-configurator.js | 26 +++++++++++-- lib/plugin.js | 26 +++++++++++-- lib/runner.js | 53 ++++++++++++++++++++++++-- lib/techs/istanbul.js | 15 ++++++++ lib/techs/tmpl-spec.js | 12 ++++-- lib/unmap-coverage.js | 81 ++++++++++++++++++++++++++++++++++++++++ lib/util.js | 3 ++ package.json | 16 +++++--- 9 files changed, 294 insertions(+), 19 deletions(-) create mode 100644 lib/coverage-object.js create mode 100644 lib/techs/istanbul.js create mode 100644 lib/unmap-coverage.js create mode 100644 lib/util.js diff --git a/lib/coverage-object.js b/lib/coverage-object.js new file mode 100644 index 0000000..357753d --- /dev/null +++ b/lib/coverage-object.js @@ -0,0 +1,81 @@ +var _ = require('lodash'), + inherit = require('inherit'); + +module.exports = inherit({ + __constructor: function () { + this._indices = { + }; + this.coverage = { + }; + }, + + addStatement: function (start, end, counter) { + var sourceName = start.source, + sourceObj = this._getFileObject(sourceName), + idx = this._indices[sourceName].s++; + sourceObj.statementMap[idx] = { + start: getLocation(start), + end: getLocation(end) + }; + sourceObj.s[idx] = counter; + }, + + addFunction: function (name, start, end, counter) { + var sourceName = start.source, + sourceObj = this._getFileObject(sourceName), + idx = this._indices[sourceName].f++; + sourceObj.fnMap[idx] = { + name: name, + line: start.line, + loc: { + start: getLocation(start), + end: getLocation(end) + } + }; + + sourceObj.f[idx] = counter; + }, + + addBranch: function (type, locations, counter) { + var sourceName = locations[0].start.source, + sourceObj = this._getFileObject(sourceName), + idx = this._indices[sourceName].b++; + sourceObj.branchMap[idx] = { + line: locations[0].start.line, + type: type, + locations: locations.map(function (loc) { + return { + start: getLocation(loc.start), + end: getLocation(loc.end) + }; + }) + }; + sourceObj.b[idx] = counter; + }, + + _getFileObject: function (sourceName) { + var fileObject = this.coverage[sourceName]; + if (!fileObject) { + fileObject = this.coverage[sourceName] = { + path: sourceName, + s: {}, + b: {}, + f: {}, + statementMap: {}, + fnMap: {}, + branchMap: {} + }; + + this._indices[sourceName] = { + s: 1, + b: 1, + f: 1 + }; + } + return fileObject; + } +}); + +function getLocation(soureMapLocation) { + return _.pick(soureMapLocation, 'line', 'column'); +} diff --git a/lib/node-configurator.js b/lib/node-configurator.js index 15a5a5b..a524fd7 100644 --- a/lib/node-configurator.js +++ b/lib/node-configurator.js @@ -1,6 +1,8 @@ var path = require('path'), fs = require('fs'), + _ = require('lodash'), + levels = require('enb-bem-techs/techs/levels'), files = require('enb-bem-techs/techs/files'), @@ -10,7 +12,9 @@ var path = require('path'), mergeBemdecl = require('enb-bem-techs/techs/merge-bemdecl'), references = require('./techs/references'), - spec = require('./techs/tmpl-spec'); + istanbul = require('./techs/istanbul'), + spec = require('./techs/tmpl-spec'), + instrumentedTarget = require('./util').instrumentedTarget; exports.configure = function (config, options) { var pattern = path.join(options.destPath, '*'), @@ -20,6 +24,7 @@ exports.configure = function (config, options) { var nodePath = nodeConfig.getNodePath(), sublevel = path.join(nodePath, 'blocks'), engines = options.engines, + coverageEngines = options.coverage.engines, engineTargets = []; if (fs.existsSync(sublevel)) { @@ -47,13 +52,26 @@ exports.configure = function (config, options) { engines.forEach(function (engine) { nodeConfig.addTech([engine.tech, engine.options]); - nodeConfig.addTarget(engine.target); - engineTargets.push(engine.target); + if (_.contains(coverageEngines, engine.name)) { + var instrumented = instrumentedTarget(engine.target); + nodeConfig.addTech([ + istanbul, + { source: engine.target, target: instrumented } + ]); + engineTargets.push(instrumented); + } else { + engineTargets.push(engine.target); + } }); nodeConfig.addTechs([ [references, { dirsTarget: '?.base.dirs' }], - [spec, { engines: engines, engineTargets: engineTargets, saveHtml: options.saveHtml }] + [spec, { + engines: engines, + engineTargets: engineTargets, + saveHtml: options.saveHtml, + coverageEngines: coverageEngines + }] ]); nodeConfig.addTarget('?.tmpl-spec.js'); diff --git a/lib/plugin.js b/lib/plugin.js index 1c06157..d0e175e 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -22,7 +22,14 @@ module.exports = function (helper) { _init: function (options) { var root = helper.getRootPath(), - engines = options.engines; + engines = options.engines, + coverage = options.coverage || {}; + + if (coverage === true) { + coverage = { + engines: Object.keys(engines) + }; + } return { _options: options, @@ -58,6 +65,18 @@ module.exports = function (helper) { }; }), sourceLevels: options.sourceLevels || options.levels, + coverage: _.defaults(coverage, { + engines: [], + reportDirectory: 'coverage', + exclude: [ + '**/node_modules/**', + '**/libs/**' + ], + reporters: process.env.BEM_TMPL_SPECS_COV_REPORTERS ? + process.env.BEM_TMPL_SPECS_COV_REPORTERS.split(',') + : ['lcov'] + }), + referenceDirSuffixes: options.referenceDirSuffixes || ['tmpl-specs'], saveHtml: (typeof process.env.BEM_TMPL_SPECS_SAVE_HTML === 'undefined' ? options.saveHtml : @@ -106,7 +125,8 @@ module.exports = function (helper) { destPath: options.destPath, sourceLevels: options.sourceLevels, engines: options.engines, - saveHtml: options.saveHtml + saveHtml: options.saveHtml, + coverage: options.coverage }); }); }, @@ -142,7 +162,7 @@ module.exports = function (helper) { return path.join(options.root, node, basename + '.tmpl-spec.js'); }); - return filesToRun.length && runner.run(filesToRun) + return filesToRun.length && runner.run(filesToRun, options) .fail(function (err) { if (err.stack) { console.error(err.stack); diff --git a/lib/runner.js b/lib/runner.js index 6070d69..d7f64d2 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -1,7 +1,12 @@ var vow = require('vow'), - Mocha = require('mocha'); + Mocha = require('mocha'), + istanbul = require('istanbul'), + minimatch = require('minimatch'), + _ = require('lodash'), -exports.run = function (files) { + unmapCoverageObject = require('./unmap-coverage'); + +exports.run = function (files, opts) { var defer = vow.defer(), mocha = new Mocha({ ui: 'bdd', @@ -10,7 +15,15 @@ exports.run = function (files) { mocha.files = files; mocha.run(function (failures) { - failures ? defer.reject(failures) : defer.resolve(); + function resolvePromise() { + failures ? defer.reject(failures) : defer.resolve(); + } + + if (opts.coverage.engines.length > 0) { + processCoverage(opts.coverage).done(resolvePromise); + } else { + resolvePromise(); + } process.on('exit', function () { process.exit(failures); @@ -49,3 +62,37 @@ function getReporters() { return reporters; } + +function processCoverage(coverageOpts) { + var coverage = global.__coverage__; + + return unmapCoverageObject(coverage) + .then(function (unmapedCoverage) { + return filterAndSaveCoverage(unmapedCoverage, coverageOpts); + }); +} + +function filterAndSaveCoverage(coverage, opts) { + coverage = filterCoverage(coverage, opts.exclude); + return saveCoverageReport(coverage, opts); +} + +function filterCoverage(coverage, exclude) { + return _.omit(coverage, function (value, fileName) { + return exclude.some(function (pattern) { + return minimatch(fileName, pattern, { matchBase: true }); + }); + }); +} + +function saveCoverageReport(coverage, opts) { + var defer = vow.defer(), + reporter = new istanbul.Reporter(null, opts.reportDirectory); + reporter.addAll(opts.reporters); + var collector = new istanbul.Collector(); + collector.add(coverage); + reporter.write(collector, false, function () { + defer.resolve(); + }); + return defer.promise(); +} diff --git a/lib/techs/istanbul.js b/lib/techs/istanbul.js new file mode 100644 index 0000000..4443517 --- /dev/null +++ b/lib/techs/istanbul.js @@ -0,0 +1,15 @@ +var Instrumenter = require('istanbul').Instrumenter, + fs = require('vow-fs'); + +module.exports = require('enb/lib/build-flow').create() + .name('istanbul') + .target('target', '?.instrumented.js') + .useSourceFilename('source', '?.js') + .builder(function (source) { + return fs.read(source, 'utf8') + .then(function (content) { + var instrumenter = new Instrumenter(); + return instrumenter.instrumentSync(content, source); + }); + }) + .createTech(); diff --git a/lib/techs/tmpl-spec.js b/lib/techs/tmpl-spec.js index 49f7d2c..ae9823f 100644 --- a/lib/techs/tmpl-spec.js +++ b/lib/techs/tmpl-spec.js @@ -1,15 +1,18 @@ var path = require('path'), vfs = require('enb/lib/fs/async-fs'), readAsset = vfs.read(path.join(__dirname, '..', 'assets', 'tmpl-spec.jst')), - template = require('lodash').template, + _ = require('lodash'), + template = _.template, htmlDifferFilename = require.resolve('html-differ'), - jsBeautifyFilename = require.resolve('js-beautify'); + jsBeautifyFilename = require.resolve('js-beautify'), + instrumentedTarget = require('../util').instrumentedTarget; module.exports = require('enb/lib/build-flow').create() .name('tmpl-spec') .target('target', '?.tmpl-spec.js') .defineRequiredOption('engines') .defineOption('saveHtml', false) + .defineOption('coverageEngines', []) .useSourceFilename('references', '?.references.js') .useSourceListFilenames('engineTargets', []) .needRebuild(function (cache) { @@ -25,6 +28,7 @@ module.exports = require('enb/lib/build-flow').create() references = require(referencesFilename), engines = this._engines, saveHtml = this._saveHtml, + coverageEngines = this._coverageEngines, its = []; Object.keys(references).forEach(function (name) { @@ -39,9 +43,11 @@ module.exports = require('enb/lib/build-flow').create() describe: path.basename(nodePath) + ' (' + path.dirname(nodePath) + ')', its: its, engines: engines.map(function (engine) { + var target = _.contains(coverageEngines, engine.name) ? + instrumentedTarget(engine.target) : engine.target; return { name: engine.name, - target: node.unmaskTargetName(engine.target), + target: node.unmaskTargetName(target), exportName: engine.exportName }; }), diff --git a/lib/unmap-coverage.js b/lib/unmap-coverage.js new file mode 100644 index 0000000..049f87c --- /dev/null +++ b/lib/unmap-coverage.js @@ -0,0 +1,81 @@ +'use strict'; +var vow = require('vow'), + vowFs = require('vow-fs'), + _ = require('lodash'), + istanbul = require('istanbul'), + SourceLocator = require('enb-source-map/lib/source-locator'), + + CoverageObject = require('./coverage-object'); + +function unmapCoverageObject(sourceObject) { + return vow.all(Object.keys(sourceObject).map(function (fileName) { + return vowFs.read(fileName, 'utf8').then(function (source) { + return unmapFile(fileName, sourceObject[fileName], source); + }); + })) + .then(function (coverageObjects) { + return coverageObjects.reduce(mergeCoverage); + }) + .then(function (totalCoverage) { + var withNoSources = _.omit(totalCoverage, function (value, fileName) { + return fileName in sourceObject; + }); + if (_.isEmpty(withNoSources)) { + // if no sources left after removal of originals, + // source map is absent + return totalCoverage; + } + return withNoSources; + }); +} + +function unmapFile(fileName, fileObject, source) { + var locator = new SourceLocator(fileName, source), + result = new CoverageObject(); + + Object.keys(fileObject.statementMap).forEach(function (key) { + var statement = fileObject.statementMap[key], + originalStart = locator.locate(statement.start.line, statement.start.column), + originalEnd = locator.locate(statement.end.line, statement.end.column); + result.addStatement(originalStart, originalEnd, fileObject.s[key]); + }); + + Object.keys(fileObject.fnMap).forEach(function (key) { + var fn = fileObject.fnMap[key], + loc = fn.loc, + originalStart = locator.locate(loc.start.line, loc.start.column), + originalEnd = locator.locate(loc.end.line, loc.end.column); + result.addFunction(fn.name, originalStart, originalEnd, fileObject.f[key]); + }); + + Object.keys(fileObject.branchMap).forEach(function (key) { + var branch = fileObject.branchMap[key], + locations = branch.locations.map(function (loc) { + return { + start: locator.locate(loc.start.line, loc.start.column), + end: locator.locate(loc.end.line, loc.end.column) + }; + }); + result.addBranch( + branch.type, + locations, + fileObject.b[key] + ); + }); + + return result.coverage; +} + +function mergeCoverage(first, second) { + var result = _.clone(first); + _.each(second, function(value, key) { + if (result[key]) { + result[key] = istanbul.utils.mergeFileCoverage(result[key], value); + } else { + result[key] = value; + } + }); + return result; +} + +module.exports = unmapCoverageObject; diff --git a/lib/util.js b/lib/util.js new file mode 100644 index 0000000..92d6df9 --- /dev/null +++ b/lib/util.js @@ -0,0 +1,3 @@ +exports.instrumentedTarget = function instrumentedTarget(target) { + return target + '.instr.js'; +}; diff --git a/package.json b/package.json index 31f3aee..f09beab 100644 --- a/package.json +++ b/package.json @@ -20,16 +20,20 @@ "enb-magic-factory": ">= 0.3.0 < 1.0.0" }, "dependencies": { - "enb-bem-techs": "0.1.0-rc", - "enb-bem-pseudo-levels": "0.2.3", "bem-naming": "0.3.0", + "enb-bem-pseudo-levels": "0.2.3", + "enb-bem-techs": "0.1.0-rc", + "enb-source-map": "^1.4.1", "html-differ": "1.0.4", - "vow": "0.4.7", "inherit": "2.2.2", - "mocha": "2.0.1", - "lodash": "2.4.1", + "istanbul": "^0.3.2", + "jade": "1.7.0", "js-beautify": "1.5.4", - "jade": "1.7.0" + "lodash": "2.4.1", + "minimatch": "^1.0.0", + "mocha": "2.0.0", + "vow": "0.4.7", + "vow-fs": "^0.3.3" }, "devDependencies": { "enb": ">= 0.13.0 < 1.0.0",