Code coverage report for istanbul/lib/report/html.js

Statements: 87.88% (145 / 165)      Branches: 71.25% (57 / 80)      Functions: 100% (30 / 30)      Lines: 89.31% (142 / 159)     

All files » istanbul/lib/report/ » html.js
1 /*
2 Copyright (c) 2012, Yahoo! Inc. All rights reserved.
3 Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 */
5
6 /*jslint nomen: true */
7 1 var handlebars = require('handlebars'),
8 path = require('path'),
9 SEP = path.sep || '/',
10 fs = require('fs'),
11 util = require('util'),
12 mkdirp = require('mkdirp'),
13 FileWriter = require('../util/file-writer'),
14 Report = require('./index'),
15 Store = require('../store'),
16 InsertionText = require('../util/insertion-text'),
17 TreeSummarizer = require('../util/tree-summarizer'),
18 utils = require('../object-utils'),
19 2 templateFor = function (name) { return handlebars.compile(fs.readFileSync(path.resolve(__dirname, 'templates', name + '.txt'), 'utf8')); },
20 headerTemplate = templateFor('head'),
21 footerTemplate = templateFor('foot'),
22 pathTemplate = handlebars.compile('<div class="path">{{{html}}}</div>'),
23 detailTemplate = handlebars.compile([
24 '<tr>',
25 '<td class="line-count">{{line}}</td>',
26 '<td class="line-coverage cline-{{covered}}">{{executionCount}}</td>',
27 '<td class="text">{{{text}}}</td>',
28 '</tr>\n'
29 ].join('\n\t')),
30 summaryTableHeader = [
31 '<div class="coverage-summary">',
32 '<table>',
33 '<thead>',
34 '<tr>',
35 ' <th data-col="file" data-fmt="html" data-html="true" class="file">File</th>',
36 ' <th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>',
37 ' <th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>',
38 ' <th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>',
39 ' <th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>',
40 ' <th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>',
41 ' <th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>',
42 ' <th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>',
43 ' <th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>',
44 ' <th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>',
45 '</tr>',
46 '</thead>',
47 '<tbody>'
48 ].join('\n'),
49 summaryLineTemplate = handlebars.compile([
50 '<tr>',
51 '<td class="file {{reportClasses.statements}}" data-value="{{file}}"><a href="{{output}}">{{file}}</a></td>',
52 '<td data-value="{{metrics.statements.pct}}" class="pic {{reportClasses.statements}}">{{#picture}}{{metrics.statements.pct}}{{/picture}}</td>',
53 '<td data-value="{{metrics.statements.pct}}" class="pct {{reportClasses.statements}}">{{metrics.statements.pct}}%</td>',
54 '<td data-value="{{metrics.statements.total}}" class="abs {{reportClasses.statements}}">({{metrics.statements.covered}} / {{metrics.statements.total}})</td>',
55 '<td data-value="{{metrics.branches.pct}}" class="pct {{reportClasses.branches}}">{{metrics.branches.pct}}%</td>',
56 '<td data-value="{{metrics.branches.total}}" class="abs {{reportClasses.branches}}">({{metrics.branches.covered}} / {{metrics.branches.total}})</td>',
57 '<td data-value="{{metrics.functions.pct}}" class="pct {{reportClasses.functions}}">{{metrics.functions.pct}}%</td>',
58 '<td data-value="{{metrics.functions.total}}" class="abs {{reportClasses.functions}}">({{metrics.functions.covered}} / {{metrics.functions.total}})</td>',
59 '<td data-value="{{metrics.lines.pct}}" class="pct {{reportClasses.lines}}">{{metrics.lines.pct}}%</td>',
60 '<td data-value="{{metrics.lines.total}}" class="abs {{reportClasses.lines}}">({{metrics.lines.covered}} / {{metrics.lines.total}})</td>',
61 '</tr>\n'
62 ].join('\n\t')),
63 summaryTableFooter = [
64 '</tbody>',
65 '</table>',
66 '</div>'
67 ].join('\n'),
68 seq = 0,
69 lt = '\u0001',
70 gt = '\u0002',
71 RE_LT = /</g,
72 RE_GT = />/g,
73 RE_AMP = /&/g,
74 RE_lt = /\u0001/g,
75 RE_gt = /\u0002/g;
76
77 1 handlebars.registerHelper('picture', function (opts) {
78 28 var num = Number(opts.fn(this)),
79 rest,
80 cls = '';
81 28 Eif (isFinite(num)) {
82 28 if (num === 100) {
83 12 cls = ' cover-full';
84 }
85 28 num = Math.floor(num);
86 28 rest = 100 - num;
87 28 return '<span class="cover-fill' + cls + '" style="width: ' + num + 'px;"></span>'
88 + '<span class="cover-empty" style="width:' + rest + 'px;"></span>';
89 } else {
90 return '';
91 }
92 });
93
94 1 function annotateLines(fileCoverage, structuredText) {
95 16 var lineStats = fileCoverage.l;
96 16 Iif (!lineStats) { return; }
97 16 Object.keys(lineStats).forEach(function (lineNumber) {
98 56 var count = lineStats[lineNumber];
99 56 structuredText[lineNumber].covered = count > 0 ? 'yes' : 'no';
100 });
101 16 structuredText.forEach(function (item) {
102 188 if (item.covered === null) {
103 132 item.covered = 'neutral';
104 }
105 });
106 }
107
108 1 function annotateStatements(fileCoverage, structuredText) {
109 16 var statementStats = fileCoverage.s,
110 statementMeta = fileCoverage.statementMap;
111 16 Object.keys(statementStats).forEach(function (stName) {
112 56 var count = statementStats[stName],
113 meta = statementMeta[stName],
114 type = count > 0 ? 'yes' : 'no',
115 startCol = meta.start.column,
116 endCol = meta.end.column + 1,
117 startLine = meta.start.line,
118 endLine = meta.end.line,
119 openSpan = lt + 'span class="cstat-' + type + '"' + gt,
120 closeSpan = lt + '/span' + gt,
121 text;
122
123 56 if (type === 'no') {
124 16 if (endLine !== startLine) {
125 4 endLine = startLine;
126 4 endCol = structuredText[startLine].text.originalLength();
127 }
128 16 text = structuredText[startLine].text;
129 16 text.wrap(startCol,
130 openSpan,
131 startLine === endLine ? endCol : text.originalLength(),
132 closeSpan);
133 }
134 });
135 }
136
137 1 function annotateFunctions(fileCoverage, structuredText) {
138
139 16 var fnStats = fileCoverage.f,
140 fnMeta = fileCoverage.fnMap;
141 16 Iif (!fnStats) { return; }
142 16 Object.keys(fnStats).forEach(function (fName) {
143 20 var count = fnStats[fName],
144 meta = fnMeta[fName],
145 type = count > 0 ? 'yes' : 'no',
146 startCol = meta.loc.start.column,
147 endCol = meta.loc.end.column + 1,
148 startLine = meta.loc.start.line,
149 endLine = meta.loc.end.line,
150 openSpan = lt + 'span class="fstat-' + type + '"' + gt,
151 closeSpan = lt + '/span' + gt,
152 text;
153
154 20 if (type === 'no') {
155 8 Iif (endLine !== startLine) {
156 endLine = startLine;
157 endCol = structuredText[startLine].text.originalLength();
158 }
159 8 text = structuredText[startLine].text;
160 8 text.wrap(startCol,
161 openSpan,
162 startLine === endLine ? endCol : text.originalLength(),
163 closeSpan);
164 }
165 });
166 }
167
168 1 function annotateBranches(fileCoverage, structuredText) {
169 16 var branchStats = fileCoverage.b,
170 branchMeta = fileCoverage.branchMap;
171 16 Iif (!branchStats) { return; }
172
173 16 Object.keys(branchStats).forEach(function (branchName) {
174 12 var branchArray = branchStats[branchName],
175 24 sumCount = branchArray.reduce(function (p, n) { return p + n; }, 0),
176 metaArray = branchMeta[branchName].locations,
177 i,
178 count,
179 meta,
180 type,
181 startCol,
182 endCol,
183 startLine,
184 endLine,
185 openSpan,
186 closeSpan,
187 text;
188
189 12 if (sumCount > 0) { //only highlight if partial branches are missing
190 8 for (i = 0; i < branchArray.length; i += 1) {
191 16 count = branchArray[i];
192 16 meta = metaArray[i];
193 16 type = count > 0 ? 'yes' : 'no';
194 16 startCol = meta.start.column;
195 16 endCol = meta.end.column + 1;
196 16 startLine = meta.start.line;
197 16 endLine = meta.end.line;
198 16 openSpan = lt + 'span class="branch-' + i + ' cbranch-' + type + '"' + gt;
199 16 closeSpan = lt + '/span' + gt;
200
201 16 Iif (count === 0) { //skip branches taken
202 if (endLine !== startLine) {
203 endLine = startLine;
204 endCol = structuredText[startLine].text.originalLength();
205 }
206 text = structuredText[startLine].text;
207 if (branchMeta[branchName].type === 'if') { // and 'if' is a special case since the else branch might not be visible, being non-existent
208 text.insertAt(startCol, lt + 'span class="missing-if-branch"' + gt + (i === 0 ? 'I' : 'E') + lt + '/span' + gt, true, false);
209 } else {
210 text.wrap(startCol,
211 openSpan,
212 startLine === endLine ? endCol : text.originalLength(),
213 closeSpan);
214 }
215 }
216 }
217 }
218 });
219 }
220
221 1 function customEscape(text) {
222 172 text = text.toString();
223 172 return text.replace(RE_AMP, '&amp;')
224 .replace(RE_LT, '&lt;')
225 .replace(RE_GT, '&gt;')
226 .replace(RE_lt, '<')
227 .replace(RE_gt, '>');
228 }
229
230 1 function getReportClass(stats) {
231 144 var coveragePct = stats.pct,
232 identity = 1;
233 144 Eif (coveragePct * identity === coveragePct) {
234 144 return coveragePct >= 80 ? 'high' : coveragePct >= 50 ? 'medium' : 'low';
235 } else {
236 return '';
237 }
238 }
239
240 /**
241 * a `Report` implementation that produces HTML coverage reports.
242 *
243 * Usage
244 * -----
245 *
246 * var report = require('istanbul').Report.create('html');
247 *
248 *
249 * @class HtmlReport
250 * @extends Report
251 * @constructor
252 * @param {Object} opts optional
253 * @param {String} [opts.dir] the directory in which to generate reports. Defaults to `./html-report`
254 */
255 1 function HtmlReport(opts) {
256 4 Report.call(this);
257 4 this.opts = opts || {};
258 4 this.opts.dir = this.opts.dir || path.resolve(process.cwd(), 'html-report');
259 }
260
261 1 HtmlReport.TYPE = 'html';
262 1 util.inherits(HtmlReport, Report);
263
264 1 Report.mix(HtmlReport, {
265
266 getPathHtml: function (node, linkMapper) {
267 32 var parent = node.parent,
268 nodePath = [],
269 linkPath = [],
270 i;
271
272 32 while (parent) {
273 44 nodePath.push(parent);
274 44 parent = parent.parent;
275 }
276
277 32 for (i = 0; i < nodePath.length; i += 1) {
278 44 linkPath.push('<a href="' + linkMapper.ancestor(node, i + 1) + '">'
279 + (nodePath[i].relativeName || 'All files') + '</a>');
280 }
281 32 linkPath.reverse();
282 32 return linkPath.length > 0 ? linkPath.join(' &#187; ') + ' &#187; '
283 + node.displayShortName() : '';
284 },
285
286 writeDetailPage: function (writer, node, linkMapper, templateData, sourceText, fileCoverage, metrics) {
287 16 var code = sourceText.split(/\r?\n/),
288 count = 0,
289 172 structured = code.map(function (str) { count += 1; return { line: count, covered: null, text: new InsertionText(str, true) }; }),
290 lineNum = 0;
291
292 16 structured.unshift({ line: 0, covered: null, text: new InsertionText("") });
293
294 16 templateData.metrics = metrics;
295 16 templateData.reportClass = getReportClass(templateData.metrics.statements);
296 16 templateData.pathHtml = pathTemplate({ html: this.getPathHtml(node, linkMapper) });
297 16 writer.write(headerTemplate(templateData));
298 16 writer.write('<pre><table class="coverage">\n');
299
300 16 annotateLines(fileCoverage, structured);
301 //note: order is important, since statements typically result in spanning the whole line and doing branches late
302 //causes mismatched tags
303 16 annotateBranches(fileCoverage, structured);
304 16 annotateFunctions(fileCoverage, structured);
305 16 annotateStatements(fileCoverage, structured);
306
307 16 structured.shift();
308 16 structured.forEach(function (item) {
309 172 lineNum += 1;
310 172 item.executionCount = fileCoverage.l ? fileCoverage.l[lineNum] || null : null;
311 172 item.text = customEscape(item.text);
312 172 writer.write(detailTemplate(item));
313 });
314 16 writer.write('</table></pre>\n');
315 16 writer.write(footerTemplate(templateData));
316 },
317
318 writeIndexPage: function (writer, node, linkMapper, templateData) {
319 16 var children = Array.prototype.slice.apply(node.children);
320
321 16 children.sort(function (a, b) {
322 12 return a.name < b.name ? -1 : 1;
323 });
324
325 16 templateData.metrics = node.metrics;
326 16 templateData.reportClass = getReportClass(node.metrics.statements);
327 16 templateData.pathHtml = pathTemplate({ html: this.getPathHtml(node, linkMapper) });
328
329 16 writer.write(headerTemplate(templateData));
330 16 writer.write(summaryTableHeader);
331 16 children.forEach(function (child) {
332 28 var metrics = child.metrics,
333 reportClasses = {
334 statements: getReportClass(metrics.statements),
335 lines: getReportClass(metrics.lines),
336 functions: getReportClass(metrics.functions),
337 branches: getReportClass(metrics.branches)
338 },
339 data = {
340 metrics: metrics,
341 reportClasses: reportClasses,
342 file: child.displayShortName(),
343 output: linkMapper.fromParent(child)
344 };
345 28 writer.write(summaryLineTemplate(data) + '\n');
346 });
347 16 writer.write(summaryTableFooter);
348 16 writer.write(footerTemplate(templateData));
349 },
350
351 writeFiles: function (writer, node, dir, linkMapper, templateData, collector) {
352 16 var that = this,
353 indexFile = path.resolve(dir, 'index.html'),
354 childFile;
355 16 mkdirp.sync(dir);
356 16 templateData.entity = node.name || 'All files';
357 16 if (this.opts.verbose) { console.error('Writing ' + indexFile); }
358 16 writer.writeFile(indexFile, function () {
359 16 that.writeIndexPage(writer, node, linkMapper, templateData);
360 });
361 16 node.children.forEach(function (child) {
362 28 if (child.kind === 'dir') {
363 12 that.writeFiles(writer, child, path.resolve(dir, child.relativeName), linkMapper, templateData, collector);
364 } else {
365 16 childFile = path.resolve(dir, child.relativeName + '.html');
366 16 if (that.opts.verbose) { console.error('Writing ' + childFile); }
367 16 templateData.entity = child.name;
368 16 writer.writeFile(childFile, function () {
369 16 that.writeDetailPage(writer,
370 child,
371 linkMapper,
372 templateData,
373 collector.sourceFor(child.fullPath()),
374 collector.fileCoverageFor(child.fullPath()),
375 child.metrics);
376 });
377 }
378 });
379 },
380
381 writeReport: function (collector, sync) {
382 4 var opts = this.opts,
383 dir = opts.dir,
384 dt = new Date().toString(),
385 summarizer = new TreeSummarizer(),
386 linkMapper = {
387 fromParent: function (node) {
388 28 var i = 0,
389 relativeName = node.relativeName,
390 ch;
391 28 Iif (SEP !== '/') {
392 relativeName = '';
393 for (i = 0; i < node.relativeName.length; i += 1) {
394 ch = node.relativeName.charAt(i);
395 if (ch === SEP) {
396 relativeName += '/';
397 } else {
398 relativeName += ch;
399 }
400 }
401 }
402 28 return node.kind === 'dir' ? relativeName + 'index.html' : relativeName + '.html';
403 },
404 ancestor: function (node, num) {
405 44 var href = '',
406 separated,
407 levels,
408 i,
409 j;
410 44 for (i = 0; i < num; i += 1) {
411 60 separated = node.relativeName.split(SEP);
412 60 levels = separated.length - 1;
413 60 for (j = 0; j < levels; j += 1) {
414 36 href += '../';
415 }
416 60 node = node.parent;
417 }
418 44 return href + 'index.html';
419 }
420 },
421 writer = new FileWriter(sync),
422 tree;
423
424 4 collector.files().forEach(function (key) {
425 16 summarizer.addFileCoverageSummary(key, utils.summarizeFileCoverage(collector.fileCoverageFor(key)));
426 });
427 4 tree = summarizer.getTreeSummary();
428 //console.log(JSON.stringify(tree.root, undefined, 4));
429 4 this.writeFiles(writer, tree.root, dir, linkMapper, { datetime: dt }, collector);
430 }
431 });
432
433 1 module.exports = HtmlReport;
434
435