/*
Copyright (c) 2012, Yahoo! Inc. All rights reserved.
Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
*/
/*jshint maxlen: 300 */
var handlebars = require('handlebars'),
defaults = require('./common/defaults'),
path = require('path'),
SEP = path.sep || '/',
fs = require('fs'),
util = require('util'),
FileWriter = require('../util/file-writer'),
Report = require('./index'),
Store = require('../store'),
InsertionText = require('../util/insertion-text'),
TreeSummarizer = require('../util/tree-summarizer'),
utils = require('../object-utils'),
templateFor = function (name) { return handlebars.compile(fs.readFileSync(path.resolve(__dirname, 'templates', name + '.txt'), 'utf8')); },
headerTemplate = templateFor('head'),
footerTemplate = templateFor('foot'),
pathTemplate = handlebars.compile('<div class="path">{{{html}}}</div>'),
detailTemplate = handlebars.compile([
'<tr>',
'<td class="line-count">{{#show_lines}}{{maxLines}}{{/show_lines}}</td>',
'<td class="line-coverage">{{#show_line_execution_counts fileCoverage}}{{maxLines}}{{/show_line_execution_counts}}</td>',
'<td class="text"><pre class="prettyprint lang-js">{{#show_code structured}}{{/show_code}}</pre></td>',
'</tr>\n'
].join('')),
summaryTableHeader = [
'<div class="coverage-summary">',
'<table>',
'<thead>',
'<tr>',
' <th data-col="file" data-fmt="html" data-html="true" class="file">File</th>',
' <th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>',
' <th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>',
' <th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>',
' <th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>',
' <th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>',
' <th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>',
' <th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>',
' <th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>',
' <th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>',
'</tr>',
'</thead>',
'<tbody>'
].join('\n'),
summaryLineTemplate = handlebars.compile([
'<tr>',
'<td class="file {{reportClasses.statements}}" data-value="{{file}}"><a href="{{output}}">{{file}}</a></td>',
'<td data-value="{{metrics.statements.pct}}" class="pic {{reportClasses.statements}}">{{#show_picture}}{{metrics.statements.pct}}{{/show_picture}}</td>',
'<td data-value="{{metrics.statements.pct}}" class="pct {{reportClasses.statements}}">{{metrics.statements.pct}}%</td>',
'<td data-value="{{metrics.statements.total}}" class="abs {{reportClasses.statements}}">({{metrics.statements.covered}} / {{metrics.statements.total}})</td>',
'<td data-value="{{metrics.branches.pct}}" class="pct {{reportClasses.branches}}">{{metrics.branches.pct}}%</td>',
'<td data-value="{{metrics.branches.total}}" class="abs {{reportClasses.branches}}">({{metrics.branches.covered}} / {{metrics.branches.total}})</td>',
'<td data-value="{{metrics.functions.pct}}" class="pct {{reportClasses.functions}}">{{metrics.functions.pct}}%</td>',
'<td data-value="{{metrics.functions.total}}" class="abs {{reportClasses.functions}}">({{metrics.functions.covered}} / {{metrics.functions.total}})</td>',
'<td data-value="{{metrics.lines.pct}}" class="pct {{reportClasses.lines}}">{{metrics.lines.pct}}%</td>',
'<td data-value="{{metrics.lines.total}}" class="abs {{reportClasses.lines}}">({{metrics.lines.covered}} / {{metrics.lines.total}})</td>',
'</tr>\n'
].join('\n\t')),
summaryTableFooter = [
'</tbody>',
'</table>',
'</div>'
].join('\n'),
lt = '\u0001',
gt = '\u0002',
RE_LT = /</g,
RE_GT = />/g,
RE_AMP = /&/g,
RE_lt = /\u0001/g,
RE_gt = /\u0002/g;
handlebars.registerHelper('show_picture', function (opts) {
var num = Number(opts.fn(this)),
rest,
cls = '';
if (isFinite(num)) {
if (num === 100) {
cls = ' cover-full';
}
num = Math.floor(num);
rest = 100 - num;
return '<span class="cover-fill' + cls + '" style="width: ' + num + 'px;"></span>' +
'<span class="cover-empty" style="width:' + rest + 'px;"></span>';
} else {
return '';
}
});
handlebars.registerHelper('show_ignores', function (metrics) {
var statements = metrics.statements.skipped,
functions = metrics.functions.skipped,
branches = metrics.branches.skipped,
result;
if (statements === 0 && functions === 0 && branches === 0) {
return '<span class="ignore-none">none</span>';
}
result = [];
if (statements >0) { result.push(statements === 1 ? '1 statement': statements + ' statements'); }
if (functions >0) { result.push(functions === 1 ? '1 function' : functions + ' functions'); }
if (branches >0) { result.push(branches === 1 ? '1 branch' : branches + ' branches'); }
return result.join(', ');
});
handlebars.registerHelper('show_lines', function (opts) {
var maxLines = Number(opts.fn(this)),
i,
array = [];
for (i = 0; i < maxLines; i += 1) {
array[i] = i + 1;
}
return array.join('\n');
});
handlebars.registerHelper('show_line_execution_counts', function (context, opts) {
var lines = context.l,
maxLines = Number(opts.fn(this)),
i,
lineNumber,
array = [],
covered,
value = '';
for (i = 0; i < maxLines; i += 1) {
lineNumber = i + 1;
value = ' ';
covered = 'neutral';
if (lines.hasOwnProperty(lineNumber)) {
if (lines[lineNumber] > 0) {
covered = 'yes';
value = lines[lineNumber];
} else {
covered = 'no';
}
}
array.push('<span class="cline-any cline-' + covered + '">' + value + '</span>');
}
return array.join('\n');
});
function customEscape(text) {
text = text.toString();
return text.replace(RE_AMP, '&')
.replace(RE_LT, '<')
.replace(RE_GT, '>')
.replace(RE_lt, '<')
.replace(RE_gt, '>');
}
handlebars.registerHelper('show_code', function (context /*, opts */) {
var array = [];
context.forEach(function (item) {
array.push(customEscape(item.text) || ' ');
});
return array.join('\n');
});
function title(str) {
return ' title="' + str + '" ';
}
function annotateLines(fileCoverage, structuredText) {
var lineStats = fileCoverage.l;
if (!lineStats) { return; }
Object.keys(lineStats).forEach(function (lineNumber) {
var count = lineStats[lineNumber];
structuredText[lineNumber].covered = count > 0 ? 'yes' : 'no';
});
structuredText.forEach(function (item) {
if (item.covered === null) {
item.covered = 'neutral';
}
});
}
function annotateStatements(fileCoverage, structuredText) {
var statementStats = fileCoverage.s,
statementMeta = fileCoverage.statementMap;
Object.keys(statementStats).forEach(function (stName) {
var count = statementStats[stName],
meta = statementMeta[stName],
type = count > 0 ? 'yes' : 'no',
startCol = meta.start.column,
endCol = meta.end.column + 1,
startLine = meta.start.line,
endLine = meta.end.line,
openSpan = lt + 'span class="' + (meta.skip ? 'cstat-skip' : 'cstat-no') + '"' + title('statement not covered') + gt,
closeSpan = lt + '/span' + gt,
text;
if (type === 'no') {
if (endLine !== startLine) {
endLine = startLine;
endCol = structuredText[startLine].text.originalLength();
}
text = structuredText[startLine].text;
text.wrap(startCol,
openSpan,
startLine === endLine ? endCol : text.originalLength(),
closeSpan);
}
});
}
function annotateFunctions(fileCoverage, structuredText) {
var fnStats = fileCoverage.f,
fnMeta = fileCoverage.fnMap;
if (!fnStats) { return; }
Object.keys(fnStats).forEach(function (fName) {
var count = fnStats[fName],
meta = fnMeta[fName],
type = count > 0 ? 'yes' : 'no',
startCol = meta.loc.start.column,
endCol = meta.loc.end.column + 1,
startLine = meta.loc.start.line,
endLine = meta.loc.end.line,
openSpan = lt + 'span class="' + (meta.skip ? 'fstat-skip' : 'fstat-no') + '"' + title('function not covered') + gt,
closeSpan = lt + '/span' + gt,
text;
if (type === 'no') {
if (endLine !== startLine) {
endLine = startLine;
endCol = structuredText[startLine].text.originalLength();
}
text = structuredText[startLine].text;
text.wrap(startCol,
openSpan,
startLine === endLine ? endCol : text.originalLength(),
closeSpan);
}
});
}
function annotateBranches(fileCoverage, structuredText) {
var branchStats = fileCoverage.b,
branchMeta = fileCoverage.branchMap;
if (!branchStats) { return; }
Object.keys(branchStats).forEach(function (branchName) {
var branchArray = branchStats[branchName],
sumCount = branchArray.reduce(function (p, n) { return p + n; }, 0),
metaArray = branchMeta[branchName].locations,
i,
count,
meta,
type,
startCol,
endCol,
startLine,
endLine,
openSpan,
closeSpan,
text;
if (sumCount > 0) { //only highlight if partial branches are missing
for (i = 0; i < branchArray.length; i += 1) {
count = branchArray[i];
meta = metaArray[i];
type = count > 0 ? 'yes' : 'no';
startCol = meta.start.column;
endCol = meta.end.column + 1;
startLine = meta.start.line;
endLine = meta.end.line;
openSpan = lt + 'span class="branch-' + i + ' ' + (meta.skip ? 'cbranch-skip' : 'cbranch-no') + '"' + title('branch not covered') + gt;
closeSpan = lt + '/span' + gt;
if (count === 0) { //skip branches taken
if (endLine !== startLine) {
endLine = startLine;
endCol = structuredText[startLine].text.originalLength();
}
text = structuredText[startLine].text;
if (branchMeta[branchName].type === 'if') { // and 'if' is a special case since the else branch might not be visible, being non-existent
text.insertAt(startCol, lt + 'span class="' + (meta.skip ? 'skip-if-branch' : 'missing-if-branch') + '"' +
title((i === 0 ? 'if' : 'else') + ' path not taken') + gt +
(i === 0 ? 'I' : 'E') + lt + '/span' + gt, true, false);
} else {
text.wrap(startCol,
openSpan,
startLine === endLine ? endCol : text.originalLength(),
closeSpan);
}
}
}
}
});
}
function getReportClass(stats, watermark) {
var coveragePct = stats.pct,
identity = 1;
if (coveragePct * identity === coveragePct) {
return coveragePct >= watermark[1] ? 'high' : coveragePct >= watermark[0] ? 'medium' : 'low';
} else {
return '';
}
}
/**
* a `Report` implementation that produces HTML coverage reports.
*
* Usage
* -----
*
* var report = require('istanbul').Report.create('html');
*
*
* @class HtmlReport
* @extends Report
* @module report
* @constructor
* @param {Object} opts optional
* @param {String} [opts.dir] the directory in which to generate reports. Defaults to `./html-report`
*/
function HtmlReport(opts) {
Report.call(this);
this.opts = opts || {};
this.opts.dir = this.opts.dir || path.resolve(process.cwd(), 'html-report');
this.opts.sourceStore = this.opts.sourceStore || Store.create('fslookup');
this.opts.linkMapper = this.opts.linkMapper || this.standardLinkMapper();
this.opts.writer = this.opts.writer || null;
this.opts.templateData = { datetime: Date() };
this.opts.watermarks = this.opts.watermarks || defaults.watermarks();
}
HtmlReport.TYPE = 'html';
util.inherits(HtmlReport, Report);
Report.mix(HtmlReport, {
synopsis: function () {
return 'Navigable HTML coverage report for every file and directory';
},
getPathHtml: function (node, linkMapper) {
var parent = node.parent,
nodePath = [],
linkPath = [],
i;
while (parent) {
nodePath.push(parent);
parent = parent.parent;
}
for (i = 0; i < nodePath.length; i += 1) {
linkPath.push('<a href="' + linkMapper.ancestor(node, i + 1) + '">' +
(nodePath[i].relativeName || 'All files') + '</a>');
}
linkPath.reverse();
return linkPath.length > 0 ? linkPath.join(' » ') + ' » ' +
node.displayShortName() : '';
},
fillTemplate: function (node, templateData) {
var opts = this.opts,
linkMapper = opts.linkMapper;
templateData.entity = node.name || 'All files';
templateData.metrics = node.metrics;
templateData.reportClass = getReportClass(node.metrics.statements, opts.watermarks.statements);
templateData.pathHtml = pathTemplate({ html: this.getPathHtml(node, linkMapper) });
templateData.prettify = {
js: linkMapper.asset(node, 'prettify.js'),
css: linkMapper.asset(node, 'prettify.css')
};
},
writeDetailPage: function (writer, node, fileCoverage) {
var opts = this.opts,
sourceStore = opts.sourceStore,
templateData = opts.templateData,
sourceText = fileCoverage.code && Array.isArray(fileCoverage.code) ?
fileCoverage.code.join('\n') + '\n' : sourceStore.get(fileCoverage.path),
code = sourceText.split(/(?:\r?\n)|\r/),
count = 0,
structured = code.map(function (str) { count += 1; return { line: count, covered: null, text: new InsertionText(str, true) }; }),
context;
structured.unshift({ line: 0, covered: null, text: new InsertionText("") });
this.fillTemplate(node, templateData);
writer.write(headerTemplate(templateData));
writer.write('<pre><table class="coverage">\n');
annotateLines(fileCoverage, structured);
//note: order is important, since statements typically result in spanning the whole line and doing branches late
//causes mismatched tags
annotateBranches(fileCoverage, structured);
annotateFunctions(fileCoverage, structured);
annotateStatements(fileCoverage, structured);
structured.shift();
context = {
structured: structured,
maxLines: structured.length,
fileCoverage: fileCoverage
};
writer.write(detailTemplate(context));
writer.write('</table></pre>\n');
writer.write(footerTemplate(templateData));
},
writeIndexPage: function (writer, node) {
var linkMapper = this.opts.linkMapper,
templateData = this.opts.templateData,
children = Array.prototype.slice.apply(node.children),
watermarks = this.opts.watermarks;
children.sort(function (a, b) {
return a.name < b.name ? -1 : 1;
});
this.fillTemplate(node, templateData);
writer.write(headerTemplate(templateData));
writer.write(summaryTableHeader);
children.forEach(function (child) {
var metrics = child.metrics,
reportClasses = {
statements: getReportClass(metrics.statements, watermarks.statements),
lines: getReportClass(metrics.lines, watermarks.lines),
functions: getReportClass(metrics.functions, watermarks.functions),
branches: getReportClass(metrics.branches, watermarks.branches)
},
data = {
metrics: metrics,
reportClasses: reportClasses,
file: child.displayShortName(),
output: linkMapper.fromParent(child)
};
writer.write(summaryLineTemplate(data) + '\n');
});
writer.write(summaryTableFooter);
writer.write(footerTemplate(templateData));
},
writeFiles: function (writer, node, dir, collector) {
var that = this,
indexFile = path.resolve(dir, 'index.html'),
childFile;
if (this.opts.verbose) { console.error('Writing ' + indexFile); }
writer.writeFile(indexFile, function (contentWriter) {
that.writeIndexPage(contentWriter, node);
});
node.children.forEach(function (child) {
if (child.kind === 'dir') {
that.writeFiles(writer, child, path.resolve(dir, child.relativeName), collector);
} else {
childFile = path.resolve(dir, child.relativeName + '.html');
if (that.opts.verbose) { console.error('Writing ' + childFile); }
writer.writeFile(childFile, function (contentWriter) {
that.writeDetailPage(contentWriter, child, collector.fileCoverageFor(child.fullPath()));
});
}
});
},
standardLinkMapper: function () {
return {
fromParent: function (node) {
var i = 0,
relativeName = node.relativeName,
ch;
if (SEP !== '/') {
relativeName = '';
for (i = 0; i < node.relativeName.length; i += 1) {
ch = node.relativeName.charAt(i);
if (ch === SEP) {
relativeName += '/';
} else {
relativeName += ch;
}
}
}
return node.kind === 'dir' ? relativeName + 'index.html' : relativeName + '.html';
},
ancestorHref: function (node, num) {
var href = '',
separated,
levels,
i,
j;
for (i = 0; i < num; i += 1) {
separated = node.relativeName.split(SEP);
levels = separated.length - 1;
for (j = 0; j < levels; j += 1) {
href += '../';
}
node = node.parent;
}
return href;
},
ancestor: function (node, num) {
return this.ancestorHref(node, num) + 'index.html';
},
asset: function (node, name) {
var i = 0,
parent = node.parent;
while (parent) { i += 1; parent = parent.parent; }
return this.ancestorHref(node, i) + name;
}
};
},
writeReport: function (collector, sync) {
var opts = this.opts,
dir = opts.dir,
summarizer = new TreeSummarizer(),
writer = opts.writer || new FileWriter(sync),
that = this,
tree;
collector.files().forEach(function (key) {
summarizer.addFileCoverageSummary(key, utils.summarizeFileCoverage(collector.fileCoverageFor(key)));
});
tree = summarizer.getTreeSummary();
fs.readdirSync(path.resolve(__dirname, '..', 'vendor')).forEach(function (f) {
var resolvedSource = path.resolve(__dirname, '..', 'vendor', f),
resolvedDestination = path.resolve(dir, f),
stat = fs.statSync(resolvedSource);
if (stat.isFile()) {
if (opts.verbose) {
console.log('Write asset: ' + resolvedDestination);
}
writer.copyFile(resolvedSource, resolvedDestination);
}
});
writer.on('done', function () { that.emit('done'); });
//console.log(JSON.stringify(tree.root, undefined, 4));
this.writeFiles(writer, tree.root, dir, collector);
writer.done();
}
});
module.exports = HtmlReport;