UNPKG

23.6 kBJavaScriptView Raw
1'use strict';
2
3// Adapted from:
4// Blanket https://github.com/alex-seville/blanket, copyright (c) 2013 Alex Seville, MIT licensed
5// Falafel https://github.com/substack/node-falafel, copyright (c) James Halliday, MIT licensed
6
7const Fs = require('fs');
8const Path = require('path');
9
10const BabelESLint = require('babel-eslint');
11const SourceMap = require('source-map');
12const SourceMapSupport = require('source-map-support');
13
14const Eslintrc = require('./linter/.eslintrc');
15const Transform = require('./transform');
16
17
18const internals = {
19 ext: Symbol.for('@hapi/lab/coverage/initialize'),
20 _state: Symbol.for('@hapi/lab/coverage/_state')
21};
22
23
24// Singletons due to the fact require() instrumentation is global
25
26// $lab:coverage:off$
27global[internals._state] = global[internals._state] || {
28
29 modules: new Set(),
30
31 externals: new Set(),
32
33 files: {},
34
35 _line: function (name, line) {
36
37 global[internals._state].files[name].lines[line]++;
38 },
39
40 _statement: function (name, id, line, source) {
41
42 const statement = global[internals._state].files[name].statements[line][id];
43 if (!statement.bool) {
44 statement.hit[!source] = true;
45 }
46
47 statement.hit[!!source] = true;
48 return source;
49 },
50
51 _external: function (name, line, source) {
52
53 const initialize = source[Symbol.for('@hapi/lab/coverage/initialize')];
54 if (typeof initialize !== 'function') {
55 throw new Error('Failed to find a compatible external coverage method in ' + name + ':' + line);
56 }
57
58 internals.state.externals.add(initialize());
59 return source;
60 }
61};
62
63internals.state = Object.assign({ patterns: [], sources: {} }, global[internals._state]);
64
65if (typeof global.__$$labCov === 'undefined') {
66 global.__$$labCov = global[internals._state];
67}
68// $lab:coverage:on$
69
70
71exports.instrument = function (options) {
72
73 if (options['coverage-module']) {
74 for (const name of options['coverage-module']) {
75 internals.state.modules.add(name);
76 }
77 }
78
79 internals.state.patterns.unshift(internals.pattern(options));
80 Transform.install(options, internals.prime);
81};
82
83
84internals.pattern = function (options) {
85
86 const coveragePath = options.coveragePath || '';
87 const base = internals.escape(coveragePath);
88 const excludes = options.coverageExclude ? [].concat(options.coverageExclude).map((path) => {
89
90 let isFile = false;
91 try {
92 const pathStat = Fs.statSync(Path.join(coveragePath, path));
93 isFile = pathStat.isFile();
94 }
95 catch (ex) {
96 if (ex.code !== 'ENOENT') {
97 console.error(ex);
98 }
99 }
100
101 const escaped = internals.escape(path);
102 return isFile ? escaped : `${escaped}\\/`;
103 }).join('|') : '';
104
105 const regex = '^' + base + (excludes ? (base[base.length - 1] === '/' ? '' : '\\/') + '(?!' + excludes + ')' : '');
106 return new RegExp(regex);
107};
108
109
110internals.escape = function (string) {
111
112 return string.replace(/\\/g, '/').replace(/[\^\$\.\*\+\-\?\=\!\:\|\\\/\(\)\[\]\{\}\,]/g, '\\$&');
113};
114
115
116internals.prime = function (extension) {
117
118 require.extensions[extension] = function (localModule, filename) {
119
120 for (let i = 0; i < internals.state.patterns.length; ++i) {
121 if (internals.state.patterns[i].test(filename.replace(/\\/g, '/'))) {
122 return localModule._compile(internals.instrument(filename), filename);
123 }
124 }
125
126 const src = Fs.readFileSync(filename, 'utf8');
127 return localModule._compile(Transform.transform(filename, src), filename);
128 };
129};
130
131
132internals.instrument = function (filename) {
133
134 filename = filename.replace(/\\/g, '/');
135
136 const file = Fs.readFileSync(filename, 'utf8');
137 let content = file.replace(/^\#\!.*/, '');
138 content = Transform.transform(filename, content);
139
140 const tracking = [];
141 const statements = [];
142 const chunks = content.split('');
143 let ids = 0;
144 const bypass = {};
145 const nodesByLine = {};
146
147 const addStatement = function (line, node, bool) {
148
149 const id = ++ids;
150 statements.push({
151 id,
152 loc: node.loc,
153 line,
154 bool: bool && node.type !== 'ConditionalExpression' && node.type !== 'LogicalExpression'
155 });
156 return id;
157 };
158
159 const addToLines = function (node, line) {
160
161 if (!(line in nodesByLine)) {
162 nodesByLine[line] = [];
163 }
164
165 nodesByLine[line].push(node.type);
166 };
167
168 const annotate = function (node, parent) {
169
170 const line = node.loc.start.line;
171
172 // Decorate node
173
174 node.parent = parent;
175
176 node.source = function () {
177
178 return chunks.slice(node.range[0], node.range[1]).join('');
179 };
180
181 node.set = function (s) {
182
183 chunks[node.range[0]] = s;
184 for (let i = node.range[0] + 1; i < node.range[1]; ++i) {
185 chunks[i] = '';
186 }
187 };
188
189 // Reference node types per line, to detect commented lines
190
191 addToLines(node, line);
192 const end = node.loc.end.line;
193 if (end !== line) {
194 addToLines(node, end);
195 }
196
197 // Coverage status
198
199 const bypassTests = [];
200 for (let i = node.range[0]; i <= node.range[1]; ++i) {
201 bypassTests.push(bypass[i]);
202 }
203
204 if (bypassTests.every((test) => test)) {
205 return;
206 }
207
208 // Recursively annotate the tree from the inner-most out
209
210 for (const name in node) {
211 if (name === 'parent') {
212 continue;
213 }
214
215 const children = [].concat(node[name]);
216 for (const child of children) {
217 if (child &&
218 typeof child.type === 'string') { // Identify node types
219
220 annotate(child, node);
221 }
222 }
223 }
224
225 // Annotate source code
226
227 const decoratedTypes = [
228 'IfStatement',
229 'WhileStatement',
230 'DoWhileStatement',
231 'ForStatement',
232 'ForInStatement',
233 'WithStatement'
234 ];
235
236 if (decoratedTypes.includes(node.type)) {
237 if (node.alternate &&
238 node.alternate.type !== 'BlockStatement') {
239
240 node.alternate.set(`{${node.alternate.source()}}`);
241 }
242
243 const consequent = node.consequent || node.body;
244 if (consequent.type !== 'BlockStatement') {
245 consequent.set(`{${consequent.source()}}`);
246 }
247 }
248
249 if (node.type === 'ExpressionStatement' &&
250 node.expression.value === 'use strict') {
251
252 return;
253 }
254
255 if (node.type === 'SequenceExpression') {
256 node.set(`(${node.source()})`);
257 }
258
259 const trackedTypes = [
260 'ExpressionStatement',
261 'BreakStatement',
262 'ContinueStatement',
263 'VariableDeclaration',
264 'ReturnStatement',
265 'ThrowStatement',
266 'TryStatement',
267 'IfStatement',
268 'WhileStatement',
269 'DoWhileStatement',
270 'ForStatement',
271 'ForInStatement',
272 'SwitchStatement',
273 'WithStatement',
274 'LabeledStatement'
275 ];
276
277 if (node.parent &&
278 node.parent.type === 'BlockStatement' &&
279 node.parent.parent.type.includes('FunctionExpression') &&
280 node.parent.body[0] === node) {
281
282 const id = addStatement(line, node, false);
283
284 node.set(`global.__$$labCov._statement('${filename}', ${id}, ${line}, true); ${node.source()};`);
285 }
286 else if (trackedTypes.includes(node.type) &&
287 (node.type !== 'VariableDeclaration' || (node.parent.type !== 'ForStatement' && node.parent.type !== 'ForInStatement' && node.parent.type !== 'ForOfStatement')) &&
288 node.parent.type !== 'LabeledStatement') {
289
290 tracking.push(line);
291 node.set(`global.__$$labCov._line('${filename}',${line});${node.source()}`);
292 }
293 else if (node.type === 'ConditionalExpression') {
294 const consequent = addStatement(line, node.consequent, false);
295 const alternate = addStatement(line, node.alternate, false);
296
297 node.set(`(${node.test.source()}? global.__$$labCov._statement('${filename}',${consequent},${line},(${node.consequent.source()})) : global.__$$labCov._statement('${filename}',${alternate},${line},(${node.alternate.source()})))`);
298 }
299 else if (node.type === 'LogicalExpression') {
300 const left = addStatement(line, node.left, true);
301 const right = addStatement(line, node.right, node.parent.type === 'LogicalExpression' || node.parent.type === 'IfStatement');
302
303 node.set(`(global.__$$labCov._statement(\'${filename}\',${left},${line},${node.left.source()})${node.operator}global.__$$labCov._statement(\'${filename}\',${right},${line},${node.right.source()}))`);
304 }
305 else if (node.parent &&
306 node.parent.type === 'ArrowFunctionExpression' &&
307 node.type.includes('Expression')) {
308
309 const id = addStatement(line, node, false);
310
311 node.set(`global.__$$labCov._statement('${filename}', ${id}, ${line}, ${node.source()})`);
312 }
313 else if (node.parent &&
314 node.parent.test === node &&
315 node.parent.type !== 'SwitchCase') {
316
317 const test = addStatement(line, node, true);
318
319 node.set(`global.__$$labCov._statement(\'${filename}\',${test},${line},${node.source()})`);
320 }
321 else if (node.type === 'CallExpression' &&
322 node.callee.name === 'require') {
323
324 const name = node.arguments[0].value;
325 if (internals.state.modules.has(name)) {
326 node.set(`global.__$$labCov._external(\'${filename}\',${line},${node.source()})`);
327 }
328 }
329 };
330
331 // Parse tree
332
333 const tree = BabelESLint.parse(content, Eslintrc.parserOptions);
334
335 // Process comments
336
337 // In addition to maintaining the current coverage bypass state, support
338 // comments that push that state to a stack, allowing transpiled code to
339 // skip coverage without interfering with a user-defined bypass block.
340
341 let skipStart = 0;
342 let segmentSkip = false;
343 const skipStack = [];
344
345 for (const comment of tree.comments) {
346 const directive = comment.value.match(/^\s*\$lab\:coverage\:(off|on|push|pop|ignore)\$\s*$/);
347 if (!directive) {
348 continue;
349 }
350
351 const command = directive[1];
352 if (command === 'push') {
353 skipStack.push(segmentSkip);
354 continue;
355 }
356
357 if (command === 'ignore') {
358 for (let i = comment.start - comment.loc.start.column; i < comment.start; ++i) {
359 bypass[i] = true;
360 }
361
362 continue;
363 }
364
365 let newSkipState;
366 if (command === 'pop') {
367 if (!skipStack.length) {
368 throw new Error('unable to pop coverage bypass stack');
369 }
370
371 newSkipState = skipStack.pop();
372 }
373 else {
374 newSkipState = command !== 'on';
375 }
376
377 if (newSkipState !== segmentSkip) {
378 segmentSkip = newSkipState;
379 if (newSkipState) {
380 skipStart = comment.range[1];
381 }
382 else {
383 for (let i = skipStart; i < comment.range[0]; ++i) {
384 bypass[i] = true;
385 }
386 }
387 }
388 }
389
390 // Begin code annotation
391
392 annotate(tree);
393
394 // Store original source
395
396 internals.state.sources[filename] = content.replace(/(\r\n|\n|\r)/gm, '\n').split('\n');
397
398 // Setup global report container
399
400 if (!internals.state.files[filename]) {
401 internals.state.files[filename] = {
402 statements: {},
403 lines: {},
404 commentedLines: {}
405 };
406
407 const record = internals.state.files[filename];
408 tracking.forEach((item) => {
409
410 record.lines[item] = 0;
411 });
412
413 statements.forEach((item) => {
414
415 record.statements[item.line] = record.statements[item.line] || {};
416 record.statements[item.line][item.id] = { hit: {}, bool: item.bool, loc: item.loc };
417 });
418
419 const blank = /^\s*$/;
420
421 // Compute SLOC by counting all non-blank lines, and subtract comments
422 // Don't bother with actual coverage (dealing with hits, misses and bypass is tricky) and rely only on AST
423 // Only comments that don't share the same line with something else must be subtracted
424
425 record.sloc = internals.state.sources[filename].filter((line) => !blank.test(line)).length -
426 tree.comments.map((node) => {
427
428 const start = node.loc.start.line;
429 const end = node.loc.end.line;
430 let commented = 0;
431
432 // But don't count commented white lines, cause there are already subtracted
433
434 for (let i = start; i <= end; ++i) {
435
436 // Don't consider line commented if it contains something which isn't another comment
437
438 if ((!nodesByLine[i] ||
439 !nodesByLine[i].find((type) => type !== 'Line' && type !== 'Block')) &&
440 !record.commentedLines[i]) {
441
442 record.commentedLines[i] = true;
443
444 // Acorn removes comment delimiters, so start and end lines must never be considered blank if they content is
445
446 if (i === start ||
447 i === end ||
448 !blank.test(internals.state.sources[filename][i - 1])) {
449
450 commented++;
451 }
452 }
453 }
454
455 return commented;
456 })
457 .reduce((a, b) => a + b, 0);
458 }
459
460 return chunks.join('');
461};
462
463
464internals.traverse = function (coveragePath, options) {
465
466 let nextPath = null;
467 const traverse = function (path) {
468
469 let files = [];
470 nextPath = path;
471
472 const pathStat = Fs.statSync(path);
473 if (pathStat.isFile()) {
474 return path;
475 }
476
477 Fs.readdirSync(path).forEach((filename) => {
478
479 nextPath = Path.join(path, filename);
480 const basename = Path.basename(nextPath);
481 const stat = Fs.statSync(nextPath);
482 if (stat.isDirectory() &&
483 basename[0] !== '.' &&
484 !options['coverage-flat']) {
485
486 files = files.concat(traverse(nextPath, options));
487 return;
488 }
489
490 if (stat.isFile() &&
491 basename[0] !== '.' &&
492 options.coveragePattern.test(nextPath.replace(/\\/g, '/'))) {
493
494 files.push(nextPath);
495 }
496 });
497
498 return files;
499 };
500
501 const coverageFiles = [].concat(traverse(coveragePath));
502
503 return coverageFiles.map((path) => {
504
505 return Path.resolve(path);
506 });
507};
508
509
510exports.analyze = async function (options) {
511
512 // Process coverage
513
514 const report = internals.state.files;
515 const pattern = internals.pattern(options);
516
517 const cov = {
518 sloc: 0,
519 hits: 0,
520 misses: 0,
521 percent: 0,
522 externals: 0,
523 files: []
524 };
525
526 // Filter files
527
528 const files = options['coverage-all'] ? internals.traverse(options.coveragePath, options) : Object.keys(report);
529 for (const file of files) {
530 const filename = file.replace(/\\/g, '/');
531 if (pattern.test(filename)) {
532 if (!report[filename]) {
533 internals.instrument(filename);
534 }
535
536 report[filename].source = internals.state.sources[filename] || [];
537 const data = await internals.file(filename, report[filename], options);
538
539 cov.files.push(data);
540 cov.hits += data.hits;
541 cov.misses += data.misses;
542 cov.sloc += data.sloc;
543 cov.externals += data.externals ? data.externals.length : 0;
544 }
545 }
546
547 // Sort files based on directory structure
548
549 cov.files.sort((a, b) => {
550
551 const segmentsA = a.filename.split('/');
552 const segmentsB = b.filename.split('/');
553
554 const al = segmentsA.length;
555 const bl = segmentsB.length;
556
557 for (let i = 0; i < al && i < bl; ++i) {
558
559 if (segmentsA[i] === segmentsB[i]) {
560 continue;
561 }
562
563 const lastA = i + 1 === al;
564 const lastB = i + 1 === bl;
565
566 if (lastA !== lastB) {
567 return lastA ? -1 : 1;
568 }
569
570 return segmentsA[i] < segmentsB[i] ? -1 : 1;
571 }
572
573 return segmentsA.length < segmentsB.length ? -1 : 1;
574 });
575
576 // Calculate coverage percentage
577
578 if (cov.sloc > 0) {
579 cov.percent = (cov.hits / cov.sloc) * 100;
580 }
581
582 return cov;
583};
584
585
586internals.addSourceMapsInformation = function (smc, ret, num) {
587
588 const source = ret.source[num];
589 const position = {
590 source: ret.filename,
591 line: num,
592 // when using 0 column, it sometimes miss the original line
593 column: source.source.length
594 };
595 let originalPosition = smc.originalPositionFor(position);
596
597 // Ensure folder separator to be url-friendly
598 source.originalFilename = originalPosition.source && originalPosition.source.replace(/\\/g, '/');
599 source.originalLine = originalPosition.line;
600
601 if (!ret.sourcemaps) {
602 ret.sourcemaps = true;
603 }
604
605 if (source.chunks) {
606 source.chunks.forEach((chunk) => {
607 // Also add source map information on chunks
608 originalPosition = smc.originalPositionFor({ line: num, column: chunk.column });
609 chunk.originalFilename = originalPosition.source;
610 chunk.originalLine = originalPosition.line;
611 chunk.originalColumn = originalPosition.column;
612 });
613 }
614};
615
616
617internals.file = async function (filename, data, options) {
618
619 const ret = {
620 // Ensure folder separator to be url-friendly
621 filename: filename.replace(Path.join(process.cwd(), '/').replace(/\\/g, '/'), ''),
622 percent: 0,
623 hits: 0,
624 misses: 0,
625 sloc: data.sloc,
626 source: {},
627 externals: internals.external(filename)
628 };
629
630 // Use sourcemap consumer rather than SourceMapSupport.mapSourcePosition itself which perform path transformations
631
632 let sourcemap = null;
633 if (options.sourcemaps) {
634 sourcemap = SourceMapSupport.retrieveSourceMap(ret.filename);
635 if (!sourcemap) {
636 const smre = /\/\/\#.*data:application\/json[^,]+base64,.*\r?\n?$/;
637 let sourceIndex = data.source.length - 1;
638 while (sourceIndex >= 0 && !smre.test(data.source[sourceIndex])) {
639 sourceIndex--;
640 }
641
642 if (sourceIndex >= 0) {
643 const re = /(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)|(?:\/\*[@#][ \t]+sourceMappingURL=([^\*]+?)[ \t]*(?:\*\/)[ \t]*$)/mg;
644 let lastMatch;
645 let match;
646 while (match = re.exec(data.source[sourceIndex])) {
647 lastMatch = match;
648 }
649
650 sourcemap = {
651 url: lastMatch[1],
652 map: Buffer.from(lastMatch[1].slice(lastMatch[1].indexOf(',') + 1), 'base64').toString()
653 };
654 }
655 }
656 }
657
658 const smc = sourcemap ? await new SourceMap.SourceMapConsumer(sourcemap.map) : null;
659
660 // Process each line of code
661
662 data.source.forEach((line, num) => {
663
664 num++;
665
666 let isMiss = false;
667 ret.source[num] = {
668 source: line
669 };
670
671 if (data.lines[num] === 0) {
672 isMiss = true;
673 ret.misses++;
674 }
675 else if (line) {
676 if (data.statements[num]) {
677 const mask = new Array(line.length);
678 Object.keys(data.statements[num]).forEach((id) => {
679
680 const statement = data.statements[num][id];
681 if (statement.hit.true &&
682 statement.hit.false) {
683
684 return;
685 }
686
687 if (statement.loc.start.line !== num) {
688 data.statements[statement.loc.start.line] = data.statements[statement.loc.start.line] || {};
689 data.statements[statement.loc.start.line][id] = statement;
690 return;
691 }
692
693 if (statement.loc.end.line !== num) {
694 data.statements[statement.loc.end.line] = data.statements[statement.loc.end.line] || {};
695 data.statements[statement.loc.end.line][id] = {
696 hit: statement.hit,
697 loc: {
698 start: {
699 line: statement.loc.end.line,
700 column: 0
701 },
702 end: {
703 line: statement.loc.end.line,
704 column: statement.loc.end.column
705 }
706 }
707 };
708
709 statement.loc.end.column = line.length;
710 }
711
712 isMiss = true;
713 const issue = statement.hit.true ? 'true' : (statement.hit.false ? 'false' : 'never');
714 for (let i = statement.loc.start.column; i < statement.loc.end.column; ++i) {
715 mask[i] = issue;
716 }
717 });
718
719 const chunks = [];
720
721 let from = 0;
722 for (let i = 1; i < mask.length; ++i) {
723 if (mask[i] !== mask[i - 1]) {
724 chunks.push({ source: line.slice(from, i), miss: mask[i - 1], column: from });
725 from = i;
726 }
727 }
728
729 chunks.push({ source: line.slice(from), miss: mask[from], column: from });
730
731 if (isMiss) {
732 ret.source[num].chunks = chunks;
733 ret.misses++;
734 }
735 else {
736 ret.hits++;
737 }
738 }
739 else if (!data.commentedLines[num] &&
740 line.trim()) {
741
742 ret.hits++; // Only increment hits if the current line isn't blank and commented
743 }
744 }
745
746 if (smc) {
747 internals.addSourceMapsInformation(smc, ret, num);
748 }
749
750 ret.source[num].hits = data.lines[num];
751 ret.source[num].miss = isMiss;
752 });
753
754 ret.percent = ret.hits / ret.sloc * 100;
755 return ret;
756};
757
758
759internals.external = function (filename) {
760
761 filename = Path.normalize(filename);
762
763 const reports = [];
764 for (const external of internals.state.externals) {
765 const report = external.report(filename);
766 if (report) {
767 const items = [].concat(report);
768 for (const item of items) {
769 reports.push(Object.assign({}, item, { source: external.name }));
770 }
771 }
772 }
773
774 return reports.length ? reports : null;
775};