UNPKG

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