UNPKG

gemini

Version:
333 lines (292 loc) 11.1 kB
'use strict'; var inherit = require('inherit'), _ = require('lodash'), path = require('path'), url = require('url'), Promise = require('bluebird'), chalk = require('chalk'), fs = require('fs-extra'), fetch = require('node-fetch'), css = require('css'), mm = require('micromatch'), coverage = require('gemini-coverage').api, {coverage: {coverageLevel}} = require('gemini-core'), SourceMapConsumer = require('source-map').SourceMapConsumer; const Coverage = module.exports = inherit({ __constructor: function(config) { this.config = config; this.byURL = {}; this.covDir = path.resolve(this.config.system.projectRoot, 'gemini-coverage'); this.out = {}; this._warned = {}; }, addStatsForBrowser: function(stats, browserId) { _.forEach(stats, function(coverage, url) { if (coverage.ignored) { this._warnInnacurate(url, coverage.message); return; } this.byURL[url] = this.byURL[url] || { sourceFile: this._urlToFilePath(url, browserId), coverage: {} }; _.assignWith(this.byURL[url].coverage, coverage, coverageLevel.merge); }.bind(this)); }, _warnInnacurate: function(url, message) { if (this._warned[url]) { return; } console.warn(chalk.yellow('WARNING:'), chalk.blue(url), 'may have inaccurate coverage report'); console.warn(message); this._warned[url] = true; }, _urlToFilePath: function(url, browserId) { const config = this.config; const rootUrl = config.forBrowser(browserId).rootUrl; const relPath = config.system.coverage.map(url, rootUrl); return path.resolve(config.system.sourceRoot, relPath); }, processStats: function() { var _this = this; return fs.mkdirsAsync(this.covDir) .catch(function(err) { // ignoring the fail if directory already exists. if (err.code !== 'EEXIST') { return Promise.reject(err); } }) .then(function() { return Promise.all(Object.keys(_this.byURL).map(function(url) { return _this.processCSS(url); })) .then(function() { return _this.prepareOutputStats(); }) .then(function(data) { return _this.writeStatsJson(data); }) .then(function(data) { if (!_this.config.system.coverage.html) { return; } return coverage.gen({ sourceRoot: _this.config.system.sourceRoot, destDir: path.join(_this.config.system.projectRoot, 'gemini-coverage') }, { coverage: data }); }); }); }, processCSS: function(url) { var _this = this, cssPath = this.byURL[url].sourceFile, cssRelPath = path.relative(this.config.system.sourceRoot, cssPath); if (this.fileExcluded(cssRelPath)) { return; } return readUrlOrFile(url, cssPath) .then(function(content) { var ast = css.parse(content); return Promise.all([ ast, content, _this.getSourceMap(ast, cssPath, url) ]); }) .spread(function(ast, fileContent, map) { var ctx = {media: 0}, out = _this.out; for (var r = 0; r < ast.stylesheet.rules.length; r++) { _this.processRule( ast.stylesheet.rules[r], { url: url, filePath: cssPath, out: out, map: map, docDir: path.dirname(cssPath) }, ctx); } }); }, getSourceMap: function(ast, cssPath, cssUrl) { var sourceMapUrl = this.findSourceMapPragma(ast); if (!sourceMapUrl) { return; } var base64Prefix = /^data:application\/json;(?:charset=utf-8;)?base64,/; if (base64Prefix.test(sourceMapUrl)) { var base64Str = sourceMapUrl.replace(base64Prefix, ''), sourceMapStr = new Buffer(base64Str, 'base64').toString('utf8'); return new SourceMapConsumer(JSON.parse(sourceMapStr)); } return readUrlOrFile( url.resolve(cssUrl, sourceMapUrl), path.resolve(path.dirname(cssPath), sourceMapUrl) ) .catch(function(err) { if (err.code !== 'ENOENT') { return Promise.reject(err); } }) .then(function(map) { return new SourceMapConsumer(JSON.parse(map)); }); }, findSourceMapPragma: function(ast) { var re = /^#\s*sourceMappingUrl=([^\s]+)/i, lastRule = _.last(ast.stylesheet.rules), match = lastRule.type === 'comment' && lastRule.comment.match(re); return match && decodeURI(match[1]); }, processRule: function(rule, opts, ctx) { if (rule.type !== 'rule') { if (rule.type === 'media') { rule.rules.forEach(function(childRule) { opts.group = true; this.processRule( childRule, opts, ctx); }, this); ctx.media++; } return; } var ruleCoverage = _.reduce(rule.selectors, function(coverage, selector) { var key = selector; if (opts.group) { key = '?' + ctx.media + ':' + key; } return coverageLevel.merge(coverage, this.byURL[opts.url].coverage[key]); }.bind(this), coverageLevel.NONE); var sourceStart = getPosition(rule.position.start, opts.url, opts.map), sourceEnd = getPosition(rule.position.end, opts.url, opts.map), // Synchronous realpath() is used because of original method is totally synchronous and recursive, // while the order of recursive calls is important. Making the code being asynchronous won't make // much benefit considering only one async call can be executed to guarantee the calling order. src = path.relative( this.config.system.sourceRoot, fs.realpathSync(sourceStart.source ? path.resolve(opts.docDir, sourceStart.source) : opts.filePath) ), block = this._getSourceBlock(src, sourceStart, sourceEnd); block.coverage = coverageLevel.merge(block.coverage, ruleCoverage); }, _getSourceBlock: function(source, start, end) { var blocks = (this.out[source] = this.out[source] || {}), key = [start.line, start.column, end.line, end.column].join(':'); if (!blocks[key]) { blocks[key] = { start: { line: start.line, column: start.column }, end: { line: end.line, column: end.column }, coverage: coverageLevel.NONE }; } return blocks[key]; }, fileExcluded: function(path) { return this.config.system.coverage.exclude.some(function(excludePattern) { return mm.isMatch(path, excludePattern, {matchBase: true}); }); }, writeStatsJson: function(data) { return fs.writeFileAsync( path.join(this.covDir, 'coverage.json'), JSON.stringify(data, null, 4), 'utf8' ).then(() => data); }, prepareOutputStats: function() { var _this = this, stat = {total: 0, covered: 0}; return { files: Object.keys(this.out) .filter(function(file) { return !_this.fileExcluded(file); }) .map(function(file) { var covered = 0, blocks = Object.keys(_this.out[file]).map(function(blockKey) { var block = _this.out[file][blockKey]; if (block.coverage === 'full') { covered++; } return { start: block.start, end: block.end, type: coverageToType(block.coverage) }; }); stat.covered += covered; stat.total += blocks.length; return { file: file, blocks: blocks, stat: { total: blocks.length, covered: covered, percent: Math.round(covered / blocks.length * 100) } }; }), total: stat.total, covered: stat.covered, percent: Math.round(stat.covered / stat.total * 100) }; } }, { create: function(config) { return new Coverage(config); } }); function getPosition(originalPos, file, map) { // amend rule position via source map if available if (map) { map = map.originalPositionFor({ line: originalPos.line, column: originalPos.column }); if (map.source) { return map; } else { console.warn( 'Source map does not provide mapping for %s [%s:%s]. Report might be inaccurate.', file, originalPos.line, originalPos.column); } } // return original parser position if source map is not available or it doesn't provide mapping return originalPos; } function readUrlOrFile(url, file) { return readUrl() .catch(function() { return fs.readFileAsync(file, 'utf8'); }); } function readUrl(url) { return Promise.resolve(fetch(url)) .then((res) => res.text()); } function coverageToType(coverage) { switch (coverage) { case coverageLevel.FULL: return 'covered'; case coverageLevel.PARTIAL: return 'partial'; case coverageLevel.NONE: return 'none'; } }