2 Copyright (c) 2012, Yahoo! Inc. All rights reserved.
3 Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
6 /*jshint maxlen: 300 */
7 var handlebars = require('handlebars'),
8 defaults = require('./common/defaults'),
9 path = require('path'),
11 util = require('util'),
12 FileWriter = require('../util/file-writer'),
13 Report = require('./index'),
14 Store = require('../store'),
15 InsertionText = require('../util/insertion-text'),
16 TreeSummarizer = require('../util/tree-summarizer'),
17 utils = require('../object-utils'),
18 templateFor = function (name) { return handlebars.compile(fs.readFileSync(path.resolve(__dirname, 'templates', name + '.txt'), 'utf8')); },
19 headerTemplate = templateFor('head'),
20 footerTemplate = templateFor('foot'),
21 pathTemplate = handlebars.compile('<div class="path">{{{html}}}</div>'),
22 detailTemplate = handlebars.compile([
24 '<td class="line-count">{{#show_lines}}{{maxLines}}{{/show_lines}}</td>',
25 '<td class="line-coverage">{{#show_line_execution_counts fileCoverage}}{{maxLines}}{{/show_line_execution_counts}}</td>',
26 '<td class="text"><pre class="prettyprint lang-js">{{#show_code structured}}{{/show_code}}</pre></td>',
29 summaryTableHeader = [
30 '<div class="coverage-summary">',
34 ' <th data-col="file" data-fmt="html" data-html="true" class="file">File</th>',
35 ' <th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>',
36 ' <th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>',
37 ' <th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>',
38 ' <th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>',
39 ' <th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>',
40 ' <th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>',
41 ' <th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>',
42 ' <th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>',
43 ' <th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>',
48 summaryLineTemplate = handlebars.compile([
50 '<td class="file {{reportClasses.statements}}" data-value="{{file}}"><a href="{{output}}">{{file}}</a></td>',
51 '<td data-value="{{metrics.statements.pct}}" class="pic {{reportClasses.statements}}">{{#show_picture}}{{metrics.statements.pct}}{{/show_picture}}</td>',
52 '<td data-value="{{metrics.statements.pct}}" class="pct {{reportClasses.statements}}">{{metrics.statements.pct}}%</td>',
53 '<td data-value="{{metrics.statements.total}}" class="abs {{reportClasses.statements}}">({{metrics.statements.covered}} / {{metrics.statements.total}})</td>',
54 '<td data-value="{{metrics.branches.pct}}" class="pct {{reportClasses.branches}}">{{metrics.branches.pct}}%</td>',
55 '<td data-value="{{metrics.branches.total}}" class="abs {{reportClasses.branches}}">({{metrics.branches.covered}} / {{metrics.branches.total}})</td>',
56 '<td data-value="{{metrics.functions.pct}}" class="pct {{reportClasses.functions}}">{{metrics.functions.pct}}%</td>',
57 '<td data-value="{{metrics.functions.total}}" class="abs {{reportClasses.functions}}">({{metrics.functions.covered}} / {{metrics.functions.total}})</td>',
58 '<td data-value="{{metrics.lines.pct}}" class="pct {{reportClasses.lines}}">{{metrics.lines.pct}}%</td>',
59 '<td data-value="{{metrics.lines.total}}" class="abs {{reportClasses.lines}}">({{metrics.lines.covered}} / {{metrics.lines.total}})</td>',
62 summaryTableFooter = [
75 handlebars.registerHelper('show_picture', function (opts) {
76 var num = Number(opts.fn(this)),
83 num = Math.floor(num);
85 return '<span class="cover-fill' + cls + '" style="width: ' + num + 'px;"></span>' +
86 '<span class="cover-empty" style="width:' + rest + 'px;"></span>';
92 handlebars.registerHelper('show_ignores', function (metrics) {
93 var statements = metrics.statements.skipped,
94 functions = metrics.functions.skipped,
95 branches = metrics.branches.skipped,
98 if (statements === 0 && functions === 0 && branches === 0) {
99 return '<span class="ignore-none">none</span>';
103 if (statements >0) { result.push(statements === 1 ? '1 statement': statements + ' statements'); }
104 if (functions >0) { result.push(functions === 1 ? '1 function' : functions + ' functions'); }
105 if (branches >0) { result.push(branches === 1 ? '1 branch' : branches + ' branches'); }
107 return result.join(', ');
110 handlebars.registerHelper('show_lines', function (opts) {
111 var maxLines = Number(opts.fn(this)),
115 for (i = 0; i < maxLines; i += 1) {
118 return array.join('\n');
121 handlebars.registerHelper('show_line_execution_counts', function (context, opts) {
122 var lines = context.l,
123 maxLines = Number(opts.fn(this)),
130 for (i = 0; i < maxLines; i += 1) {
134 if (lines.hasOwnProperty(lineNumber)) {
135 if (lines[lineNumber] > 0) {
137 value = lines[lineNumber];
142 array.push('<span class="cline-any cline-' + covered + '">' + value + '</span>');
144 return array.join('\n');
147 function customEscape(text) {
148 text = text.toString();
149 return text.replace(RE_AMP, '&')
150 .replace(RE_LT, '<')
151 .replace(RE_GT, '>')
153 .replace(RE_gt, '>');
156 handlebars.registerHelper('show_code', function (context /*, opts */) {
159 context.forEach(function (item) {
160 array.push(customEscape(item.text) || ' ');
162 return array.join('\n');
165 function title(str) {
166 return ' title="' + str + '" ';
169 function annotateLines(fileCoverage, structuredText) {
170 var lineStats = fileCoverage.l;
171 if (!lineStats) { return; }
172 Object.keys(lineStats).forEach(function (lineNumber) {
173 var count = lineStats[lineNumber];
174 if (structuredText[lineNumber]) {
175 structuredText[lineNumber].covered = count > 0 ? 'yes' : 'no';
178 structuredText.forEach(function (item) {
179 if (item.covered === null) {
180 item.covered = 'neutral';
185 function annotateStatements(fileCoverage, structuredText) {
186 var statementStats = fileCoverage.s,
187 statementMeta = fileCoverage.statementMap;
188 Object.keys(statementStats).forEach(function (stName) {
189 var count = statementStats[stName],
190 meta = statementMeta[stName],
191 type = count > 0 ? 'yes' : 'no',
192 startCol = meta.start.column,
193 endCol = meta.end.column + 1,
194 startLine = meta.start.line,
195 endLine = meta.end.line,
196 openSpan = lt + 'span class="' + (meta.skip ? 'cstat-skip' : 'cstat-no') + '"' + title('statement not covered') + gt,
197 closeSpan = lt + '/span' + gt,
201 if (endLine !== startLine) {
203 endCol = structuredText[startLine].text.originalLength();
205 text = structuredText[startLine].text;
208 startLine === endLine ? endCol : text.originalLength(),
214 function annotateFunctions(fileCoverage, structuredText) {
216 var fnStats = fileCoverage.f,
217 fnMeta = fileCoverage.fnMap;
218 if (!fnStats) { return; }
219 Object.keys(fnStats).forEach(function (fName) {
220 var count = fnStats[fName],
221 meta = fnMeta[fName],
222 type = count > 0 ? 'yes' : 'no',
223 startCol = meta.loc.start.column,
224 endCol = meta.loc.end.column + 1,
225 startLine = meta.loc.start.line,
226 endLine = meta.loc.end.line,
227 openSpan = lt + 'span class="' + (meta.skip ? 'fstat-skip' : 'fstat-no') + '"' + title('function not covered') + gt,
228 closeSpan = lt + '/span' + gt,
232 if (endLine !== startLine) {
234 endCol = structuredText[startLine].text.originalLength();
236 text = structuredText[startLine].text;
239 startLine === endLine ? endCol : text.originalLength(),
245 function annotateBranches(fileCoverage, structuredText) {
246 var branchStats = fileCoverage.b,
247 branchMeta = fileCoverage.branchMap;
248 if (!branchStats) { return; }
250 Object.keys(branchStats).forEach(function (branchName) {
251 var branchArray = branchStats[branchName],
252 sumCount = branchArray.reduce(function (p, n) { return p + n; }, 0),
253 metaArray = branchMeta[branchName].locations,
266 if (sumCount > 0) { //only highlight if partial branches are missing
267 for (i = 0; i < branchArray.length; i += 1) {
268 count = branchArray[i];
270 type = count > 0 ? 'yes' : 'no';
271 startCol = meta.start.column;
272 endCol = meta.end.column + 1;
273 startLine = meta.start.line;
274 endLine = meta.end.line;
275 openSpan = lt + 'span class="branch-' + i + ' ' + (meta.skip ? 'cbranch-skip' : 'cbranch-no') + '"' + title('branch not covered') + gt;
276 closeSpan = lt + '/span' + gt;
278 if (count === 0) { //skip branches taken
279 if (endLine !== startLine) {
281 endCol = structuredText[startLine].text.originalLength();
283 text = structuredText[startLine].text;
284 if (branchMeta[branchName].type === 'if') { // and 'if' is a special case since the else branch might not be visible, being non-existent
285 text.insertAt(startCol, lt + 'span class="' + (meta.skip ? 'skip-if-branch' : 'missing-if-branch') + '"' +
286 title((i === 0 ? 'if' : 'else') + ' path not taken') + gt +
287 (i === 0 ? 'I' : 'E') + lt + '/span' + gt, true, false);
291 startLine === endLine ? endCol : text.originalLength(),
300 function getReportClass(stats, watermark) {
301 var coveragePct = stats.pct,
303 if (coveragePct * identity === coveragePct) {
304 return coveragePct >= watermark[1] ? 'high' : coveragePct >= watermark[0] ? 'medium' : 'low';
310 function cleanPath(name) {
311 var SEP = path.sep || '/';
312 return (SEP !== '/') ? name.split(SEP).join('/') : name;
315 function isEmptySourceStore(sourceStore) {
320 var cache = sourceStore.sourceCache;
321 return cache && !Object.keys(cache).length;
325 * a `Report` implementation that produces HTML coverage reports.
330 * var report = require('istanbul').Report.create('html');
337 * @param {Object} opts optional
338 * @param {String} [opts.dir] the directory in which to generate reports. Defaults to `./html-report`
340 function HtmlReport(opts) {
342 this.opts = opts || {};
343 this.opts.dir = this.opts.dir || path.resolve(process.cwd(), 'html-report');
344 this.opts.sourceStore = isEmptySourceStore(this.opts.sourceStore) ?
345 Store.create('fslookup') : this.opts.sourceStore;
346 this.opts.linkMapper = this.opts.linkMapper || this.standardLinkMapper();
347 this.opts.writer = this.opts.writer || null;
348 this.opts.templateData = { datetime: Date() };
349 this.opts.watermarks = this.opts.watermarks || defaults.watermarks();
352 HtmlReport.TYPE = 'html';
353 util.inherits(HtmlReport, Report);
355 Report.mix(HtmlReport, {
357 synopsis: function () {
358 return 'Navigable HTML coverage report for every file and directory';
361 getPathHtml: function (node, linkMapper) {
362 var parent = node.parent,
368 nodePath.push(parent);
369 parent = parent.parent;
372 for (i = 0; i < nodePath.length; i += 1) {
373 linkPath.push('<a href="' + linkMapper.ancestor(node, i + 1) + '">' +
374 (cleanPath(nodePath[i].relativeName) || 'All files') + '</a>');
377 return linkPath.length > 0 ? linkPath.join(' » ') + ' » ' +
378 cleanPath(node.displayShortName()) : '';
381 fillTemplate: function (node, templateData) {
382 var opts = this.opts,
383 linkMapper = opts.linkMapper;
385 templateData.entity = node.name || 'All files';
386 templateData.metrics = node.metrics;
387 templateData.reportClass = getReportClass(node.metrics.statements, opts.watermarks.statements);
388 templateData.pathHtml = pathTemplate({ html: this.getPathHtml(node, linkMapper) });
389 templateData.base = {
390 css: linkMapper.asset(node, 'base.css')
392 templateData.sorter = {
393 js: linkMapper.asset(node, 'sorter.js'),
394 image: linkMapper.asset(node, 'sort-arrow-sprite.png')
396 templateData.prettify = {
397 js: linkMapper.asset(node, 'prettify.js'),
398 css: linkMapper.asset(node, 'prettify.css')
401 writeDetailPage: function (writer, node, fileCoverage) {
402 var opts = this.opts,
403 sourceStore = opts.sourceStore,
404 templateData = opts.templateData,
405 sourceText = fileCoverage.code && Array.isArray(fileCoverage.code) ?
406 fileCoverage.code.join('\n') + '\n' : sourceStore.get(fileCoverage.path),
407 code = sourceText.split(/(?:\r?\n)|\r/),
409 structured = code.map(function (str) { count += 1; return { line: count, covered: null, text: new InsertionText(str, true) }; }),
412 structured.unshift({ line: 0, covered: null, text: new InsertionText("") });
414 this.fillTemplate(node, templateData);
415 writer.write(headerTemplate(templateData));
416 writer.write('<pre><table class="coverage">\n');
418 annotateLines(fileCoverage, structured);
419 //note: order is important, since statements typically result in spanning the whole line and doing branches late
420 //causes mismatched tags
421 annotateBranches(fileCoverage, structured);
422 annotateFunctions(fileCoverage, structured);
423 annotateStatements(fileCoverage, structured);
427 structured: structured,
428 maxLines: structured.length,
429 fileCoverage: fileCoverage
431 writer.write(detailTemplate(context));
432 writer.write('</table></pre>\n');
433 writer.write(footerTemplate(templateData));
436 writeIndexPage: function (writer, node) {
437 var linkMapper = this.opts.linkMapper,
438 templateData = this.opts.templateData,
439 children = Array.prototype.slice.apply(node.children),
440 watermarks = this.opts.watermarks;
442 children.sort(function (a, b) {
443 return a.name < b.name ? -1 : 1;
446 this.fillTemplate(node, templateData);
447 writer.write(headerTemplate(templateData));
448 writer.write(summaryTableHeader);
449 children.forEach(function (child) {
450 var metrics = child.metrics,
452 statements: getReportClass(metrics.statements, watermarks.statements),
453 lines: getReportClass(metrics.lines, watermarks.lines),
454 functions: getReportClass(metrics.functions, watermarks.functions),
455 branches: getReportClass(metrics.branches, watermarks.branches)
459 reportClasses: reportClasses,
460 file: cleanPath(child.displayShortName()),
461 output: linkMapper.fromParent(child)
463 writer.write(summaryLineTemplate(data) + '\n');
465 writer.write(summaryTableFooter);
466 writer.write(footerTemplate(templateData));
469 writeFiles: function (writer, node, dir, collector) {
471 indexFile = path.resolve(dir, 'index.html'),
473 if (this.opts.verbose) { console.error('Writing ' + indexFile); }
474 writer.writeFile(indexFile, function (contentWriter) {
475 that.writeIndexPage(contentWriter, node);
477 node.children.forEach(function (child) {
478 if (child.kind === 'dir') {
479 that.writeFiles(writer, child, path.resolve(dir, child.relativeName), collector);
481 childFile = path.resolve(dir, child.relativeName + '.html');
482 if (that.opts.verbose) { console.error('Writing ' + childFile); }
483 writer.writeFile(childFile, function (contentWriter) {
484 that.writeDetailPage(contentWriter, child, collector.fileCoverageFor(child.fullPath()));
490 standardLinkMapper: function () {
492 fromParent: function (node) {
493 var relativeName = cleanPath(node.relativeName);
495 return node.kind === 'dir' ? relativeName + 'index.html' : relativeName + '.html';
497 ancestorHref: function (node, num) {
499 notDot = function(part) {
507 for (i = 0; i < num; i += 1) {
508 separated = cleanPath(node.relativeName).split('/').filter(notDot);
509 levels = separated.length - 1;
510 for (j = 0; j < levels; j += 1) {
517 ancestor: function (node, num) {
518 return this.ancestorHref(node, num) + 'index.html';
520 asset: function (node, name) {
522 parent = node.parent;
523 while (parent) { i += 1; parent = parent.parent; }
524 return this.ancestorHref(node, i) + name;
529 writeReport: function (collector, sync) {
530 var opts = this.opts,
532 summarizer = new TreeSummarizer(),
533 writer = opts.writer || new FileWriter(sync),
536 copyAssets = function (subdir) {
537 var srcDir = path.resolve(__dirname, '..', 'assets', subdir);
538 fs.readdirSync(srcDir).forEach(function (f) {
539 var resolvedSource = path.resolve(srcDir, f),
540 resolvedDestination = path.resolve(dir, f),
541 stat = fs.statSync(resolvedSource);
545 console.log('Write asset: ' + resolvedDestination);
547 writer.copyFile(resolvedSource, resolvedDestination);
552 collector.files().forEach(function (key) {
553 summarizer.addFileCoverageSummary(key, utils.summarizeFileCoverage(collector.fileCoverageFor(key)));
555 tree = summarizer.getTreeSummary();
556 [ '.', 'vendor'].forEach(function (subdir) {
559 writer.on('done', function () { that.emit('done'); });
560 //console.log(JSON.stringify(tree.root, undefined, 4));
561 this.writeFiles(writer, tree.root, dir, collector);
566 module.exports = HtmlReport;