From 97508b4dae180a4648421b4eab9726490603f159 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 | 79 +++++++++++++++++++++++++++++++++++++++ lib/util.js | 3 ++ package.json | 16 +++++--- 9 files changed, 292 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..9003bff --- /dev/null +++ b/lib/unmap-coverage.js @@ -0,0 +1,79 @@ +'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) { + var sources = Object.keys(sourceObject); + return vow.all(sources.map(function (fileName) { + return vowFs.read(fileName, 'utf8').then(function (source) { + return unmapFile(fileName, sourceObject[fileName], source); + }); + })) + .then(function (coverageObjects) { + return coverageObjects.reduce(mergeCoverage); + }); +} + +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 tryToStripSource(result.coverage, fileName); +} + +function tryToStripSource(coverage, source) { + var keys = Object.keys(coverage); + if (keys.length !== 1 || keys[0] !== source) { + delete coverage[source]; + } + return 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",