UNPKG

18.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
7
8// Load modules
9
10const Fs = require('fs');
11const Path = require('path');
12const Espree = require('espree');
13const SourceMapConsumer = require('source-map').SourceMapConsumer;
14const SourceMapSupport = require('source-map-support');
15const Transform = require('./transform');
16
17
18// Declare internals
19
20const internals = {
21 patterns: [],
22 sources: {}
23};
24
25
26internals.prime = function (extension) {
27
28 require.extensions[extension] = function (localModule, filename) {
29
30 for (let i = 0; i < internals.patterns.length; ++i) {
31 if (internals.patterns[i].test(filename.replace(/\\/g, '/'))) {
32 return localModule._compile(internals.instrument(filename), filename);
33 }
34 }
35
36 const src = Fs.readFileSync(filename, 'utf8');
37 return localModule._compile(Transform.transform(filename, src), filename);
38 };
39};
40
41
42exports.instrument = function (options) {
43
44 internals.patterns.unshift(internals.pattern(options));
45
46 Transform.install(options, internals.prime);
47};
48
49
50internals.pattern = function (options) {
51
52 const coveragePath = options.coveragePath || '';
53 const base = internals.escape(coveragePath);
54 const excludes = options.coverageExclude ? [].concat(options.coverageExclude).map((path) => {
55
56 let isFile = false;
57 try {
58 const pathStat = Fs.statSync(Path.join(coveragePath, path));
59 isFile = pathStat.isFile();
60 }
61 catch (ex) {
62 if (ex.code !== 'ENOENT') {
63 console.error(ex);
64 }
65 }
66
67 const escaped = internals.escape(path);
68 return isFile ? escaped : `${escaped}\\/`;
69 }).join('|') : '';
70
71 const regex = '^' + base + (excludes ? (base[base.length - 1] === '/' ? '' : '\\/') + '(?!' + excludes + ')' : '');
72 return new RegExp(regex);
73};
74
75
76internals.escape = function (string) {
77
78 return string.replace(/\\/g, '/').replace(/[\^\$\.\*\+\-\?\=\!\:\|\\\/\(\)\[\]\{\}\,]/g, '\\$&');
79};
80
81
82internals.instrument = function (filename) {
83
84 filename = filename.replace(/\\/g, '/');
85
86 const file = Fs.readFileSync(filename, 'utf8');
87 let content = file.replace(/^\#\!.*/, '');
88 content = Transform.transform(filename, content);
89
90 const tracking = [];
91 const statements = [];
92 const chunks = content.split('');
93 let ids = 0;
94 const bypass = {};
95 const nodesByLine = {};
96
97 const addStatement = function (line, node, bool) {
98
99 const id = ++ids;
100 statements.push({
101 id,
102 loc: node.loc,
103 line,
104 bool: bool && node.type !== 'ConditionalExpression' && node.type !== 'LogicalExpression'
105 });
106 return id;
107 };
108
109 const addToLines = function (node, line) {
110
111 if (!(line in nodesByLine)) {
112 nodesByLine[line] = [];
113 }
114
115 nodesByLine[line].push(node.type);
116 };
117
118 const annotate = function (node, parent) {
119
120 const line = node.loc.start.line;
121 // Decorate node
122
123 node.parent = parent;
124
125 node.source = function () {
126
127 return chunks.slice(node.range[0], node.range[1]).join('');
128 };
129
130 node.set = function (s) {
131
132 chunks[node.range[0]] = s;
133 for (let i = node.range[0] + 1; i < node.range[1]; ++i) {
134 chunks[i] = '';
135 }
136 };
137
138 // Reference node types per line, to detect commented lines
139 addToLines(node, line);
140 const end = node.loc.end.line;
141 if (end !== line) {
142 addToLines(node, end);
143 }
144
145 // Coverage status
146
147 const bypassTests = [];
148 for (let i = node.range[0]; i <= node.range[1]; ++i) {
149 bypassTests.push(bypass[i]);
150 }
151
152 if (bypassTests.every((test) => test)) {
153 return;
154 }
155
156 // Recursively annotate the tree from the inner-most out
157
158 Object.keys(node).forEach((name) => {
159
160 if (name === 'parent') {
161 return;
162 }
163
164 const children = [].concat(node[name]);
165 children.forEach((child) => {
166
167 if (child && typeof child.type === 'string') { // Identify node types
168 annotate(child, node);
169 }
170 });
171 });
172
173 // Annotate source code
174
175 const decoratedTypes = [
176 'IfStatement',
177 'WhileStatement',
178 'DoWhileStatement',
179 'ForStatement',
180 'ForInStatement',
181 'WithStatement'
182 ];
183
184 let consequent;
185
186 if (decoratedTypes.indexOf(node.type) !== -1) {
187 if (node.alternate &&
188 node.alternate.type !== 'BlockStatement') {
189
190 node.alternate.set(`{${node.alternate.source()}}`);
191 }
192
193 consequent = node.consequent || node.body;
194 if (consequent.type !== 'BlockStatement') {
195 consequent.set(`{${consequent.source()}}`);
196 }
197 }
198
199 if (node.type === 'ExpressionStatement' && node.expression.value === 'use strict') {
200 return;
201 }
202
203 const trackedTypes = [
204 'ExpressionStatement',
205 'BreakStatement',
206 'ContinueStatement',
207 'VariableDeclaration',
208 'ReturnStatement',
209 'ThrowStatement',
210 'TryStatement',
211 'IfStatement',
212 'WhileStatement',
213 'DoWhileStatement',
214 'ForStatement',
215 'ForInStatement',
216 'SwitchStatement',
217 'WithStatement',
218 'LabeledStatement'
219 ];
220
221
222 if (node.parent && node.parent.type === 'BlockStatement' &&
223 node.parent.parent.type.includes('FunctionExpression') &&
224 node.parent.body[0] === node) {
225
226 const id = addStatement(line, node, false);
227
228 node.set(`global.__$$labCov._statement('${filename}', ${id}, ${line}, true); ${node.source()};`);
229 }
230 else if (trackedTypes.indexOf(node.type) !== -1 &&
231 (node.type !== 'VariableDeclaration' || (node.parent.type !== 'ForStatement' && node.parent.type !== 'ForInStatement' && node.parent.type !== 'ForOfStatement')) &&
232 node.parent.type !== 'LabeledStatement') {
233
234 tracking.push(line);
235 node.set(`global.__$$labCov._line('${filename}',${line});${node.source()}`);
236 }
237 else if (node.type === 'ConditionalExpression') {
238 consequent = addStatement(line, node.consequent, false);
239 const alternate = addStatement(line, node.alternate, false);
240
241 node.set(`(${node.test.source()}? global.__$$labCov._statement('${filename}',${consequent},${line},(${node.consequent.source()})) : global.__$$labCov._statement('${filename}',${alternate},${line},(${node.alternate.source()})))`);
242 }
243 else if (node.type === 'LogicalExpression') {
244 const left = addStatement(line, node.left, true);
245 const right = addStatement(line, node.right, node.parent.type === 'LogicalExpression');
246
247 node.set(`(global.__$$labCov._statement(\'${filename }\',${left},${line},${node.left.source()})${node.operator}global.__$$labCov._statement(\'${filename}\',${right},${line},${node.right.source()}))`);
248 }
249 else if (node.parent && node.parent.type === 'ArrowFunctionExpression' &&
250 node.type.includes('Expression')) {
251
252 const id = addStatement(line, node, false);
253
254 node.set(`global.__$$labCov._statement('${filename}', ${id}, ${line}, ${node.source()})`);
255 }
256 else if (node.parent &&
257 node.parent.test === node &&
258 node.parent.type !== 'SwitchCase') {
259
260 const test = addStatement(line, node, true);
261
262 node.set(`global.__$$labCov._statement(\'${filename}\',${test},${line},${node.source()})`);
263 }
264 };
265
266 // Parse tree
267
268 const tree = Espree.parse(content, {
269 loc: true,
270 comment: true,
271 range: true,
272 ecmaFeatures: {
273 experimentalObjectRestSpread: true
274 },
275 ecmaVersion: 9
276 });
277
278 // Process comments
279
280 let skipStart = 0;
281 let segmentSkip = false;
282 tree.comments.forEach((comment) => {
283
284 const directive = comment.value.match(/^\s*\$lab\:coverage\:(off|on)\$\s*$/);
285 if (directive) {
286 const skip = directive[1] !== 'on';
287 if (skip !== segmentSkip) {
288 segmentSkip = skip;
289 if (skip) {
290 skipStart = comment.range[1];
291 }
292 else {
293 for (let i = skipStart; i < comment.range[0]; ++i) {
294 bypass[i] = true;
295 }
296 }
297 }
298 }
299 });
300
301 // Begin code annotation
302
303 annotate(tree);
304
305 // Store original source
306
307 const transformedFile = content.replace(/\/\/\#(.*)\r?\n?$/, '');
308 internals.sources[filename] = transformedFile.replace(/(\r\n|\n|\r)/gm, '\n').split('\n');
309
310 // Setup global report container
311 // $lab:coverage:off$
312 if (typeof global.__$$labCov === 'undefined') {
313 global.__$$labCov = {
314 files: {},
315
316 _line: function (name, line) {
317
318 global.__$$labCov.files[name].lines[line]++;
319 },
320
321 _statement: function (name, id, line, source) {
322
323 const statement = global.__$$labCov.files[name].statements[line][id];
324 if (!statement.bool) {
325 statement.hit[!source] = true;
326 }
327
328 statement.hit[!!source] = true;
329 return source;
330 }
331 };
332 } // $lab:coverage:on$
333
334 if (typeof global.__$$labCov.files[filename] === 'undefined') {
335 global.__$$labCov.files[filename] = {
336 statements: {},
337 lines: {},
338 commentedLines: {}
339 };
340
341 const record = global.__$$labCov.files[filename];
342 tracking.forEach((item) => {
343
344 record.lines[item] = 0;
345 });
346
347 statements.forEach((item) => {
348
349 record.statements[item.line] = record.statements[item.line] || {};
350 record.statements[item.line][item.id] = { hit: {}, bool: item.bool, loc: item.loc };
351 });
352
353 const blank = /^\s*$/;
354 // Compute SLOC by counting all non-blank lines, and subtract comments
355 // Don't bother with actual coverage (dealing with hits, misses and bypass is tricky) and rely only on AST
356 // Only comments that don't share the same line with something else must be subtracted
357 record.sloc = internals.sources[filename].filter((line) => !blank.test(line)).length -
358 tree.comments.map((node) => {
359
360 const start = node.loc.start.line;
361 const end = node.loc.end.line;
362 let commented = 0;
363 // But don't count commented white lines, cause there are already subtracted
364 for (let i = start; i <= end; ++i) {
365 // Don't consider line commented if it contains something which isn't another comment
366 if ((!nodesByLine[i] || !nodesByLine[i].find((type) => type !== 'Line' && type !== 'Block')) && !record.commentedLines[i]) {
367 record.commentedLines[i] = true;
368
369 // Acorn removes comment delimiters, so start and end lines must never be considered blank if they content is
370 if (i === start || i === end || !blank.test(internals.sources[filename][i - 1])) {
371 commented++;
372 }
373 }
374 }
375
376 return commented;
377 }).reduce((a, b) => a + b, 0);
378
379 if (transformedFile !== content) {
380 record.sloc++;
381 }
382 }
383
384 return chunks.join('');
385};
386
387
388exports.analyze = async function (options) {
389
390 // Process coverage (global.__$$labCov needed when labCov isn't defined)
391
392 /* $lab:coverage:off$ */ const report = global.__$$labCov || { files: {} }; /* $lab:coverage:on$ */
393 const pattern = internals.pattern(options);
394
395 const cov = {
396 sloc: 0,
397 hits: 0,
398 misses: 0,
399 percent: 0,
400 files: []
401 };
402
403 // Filter files
404
405 const files = Object.keys(report.files);
406 for (let i = 0; i < files.length; ++i) {
407 const filename = files[i];
408 if (pattern.test(filename)) {
409 report.files[filename].source = internals.sources[filename] || [];
410 const data = await internals.file(filename, report.files[filename], options);
411
412 cov.files.push(data);
413 cov.hits += data.hits;
414 cov.misses += data.misses;
415 cov.sloc += data.sloc;
416 }
417 }
418
419 // Sort files based on directory structure
420
421 cov.files.sort((a, b) => {
422
423 const segmentsA = a.filename.split('/');
424 const segmentsB = b.filename.split('/');
425
426 const al = segmentsA.length;
427 const bl = segmentsB.length;
428
429 for (let i = 0; i < al && i < bl; ++i) {
430
431 if (segmentsA[i] === segmentsB[i]) {
432 continue;
433 }
434
435 const lastA = i + 1 === al;
436 const lastB = i + 1 === bl;
437
438 if (lastA !== lastB) {
439 return lastA ? -1 : 1;
440 }
441
442 return segmentsA[i] < segmentsB[i] ? -1 : 1;
443 }
444
445 return segmentsA.length < segmentsB.length ? -1 : 1;
446 });
447
448 // Calculate coverage percentage
449
450 if (cov.sloc > 0) {
451 cov.percent = (cov.hits / cov.sloc) * 100;
452 }
453
454 return cov;
455};
456
457internals.addSourceMapsInformation = function (smc, ret, num) {
458
459 const source = ret.source[num];
460 const position = {
461 source: ret.filename,
462 line: num,
463 // when using 0 column, it sometimes miss the original line
464 column: source.source.length
465 };
466 let originalPosition = smc.originalPositionFor(position);
467
468 // Ensure folder separator to be url-friendly
469 source.originalFilename = originalPosition.source && originalPosition.source.replace(/\\/g, '/');
470 source.originalLine = originalPosition.line;
471
472 if (!ret.sourcemaps) {
473 ret.sourcemaps = true;
474 }
475
476 if (source.chunks) {
477 source.chunks.forEach((chunk) => {
478 // Also add source map information on chunks
479 originalPosition = smc.originalPositionFor({ line: num, column: chunk.column });
480 chunk.originalFilename = originalPosition.source;
481 chunk.originalLine = originalPosition.line;
482 chunk.originalColumn = originalPosition.column;
483 });
484 }
485};
486
487
488internals.file = async function (filename, data, options) {
489
490 const ret = {
491 // Ensure folder separator to be url-friendly
492 filename: filename.replace(Path.join(process.cwd(), '/').replace(/\\/g, '/'), ''),
493 percent: 0,
494 hits: 0,
495 misses: 0,
496 sloc: data.sloc,
497 source: {}
498 };
499
500 // Use sourcemap consumer rather than SourceMapSupport.mapSourcePosition itself which perform path transformations
501 const sourcemap = options.sourcemaps ? SourceMapSupport.retrieveSourceMap(ret.filename) : null;
502 const smc = sourcemap ? await new SourceMapConsumer(sourcemap.map) : null;
503
504 // Process each line of code
505 data.source.forEach((line, num) => {
506
507 num++;
508
509 let isMiss = false;
510 ret.source[num] = {
511 source: line
512 };
513
514 if (data.lines[num] === 0) {
515 isMiss = true;
516 ret.misses++;
517 }
518 else if (line) {
519 if (data.statements[num]) {
520 const mask = new Array(line.length);
521 Object.keys(data.statements[num]).forEach((id) => {
522
523 const statement = data.statements[num][id];
524 if (statement.hit.true &&
525 statement.hit.false) {
526
527 return;
528 }
529
530 if (statement.loc.start.line !== num) {
531 data.statements[statement.loc.start.line] = data.statements[statement.loc.start.line] || {};
532 data.statements[statement.loc.start.line][id] = statement;
533 return;
534 }
535
536 if (statement.loc.end.line !== num) {
537 data.statements[statement.loc.end.line] = data.statements[statement.loc.end.line] || {};
538 data.statements[statement.loc.end.line][id] = {
539 hit: statement.hit,
540 loc: {
541 start: {
542 line: statement.loc.end.line,
543 column: 0
544 },
545 end: {
546 line: statement.loc.end.line,
547 column: statement.loc.end.column
548 }
549 }
550 };
551
552 statement.loc.end.column = line.length;
553 }
554
555 isMiss = true;
556 const issue = statement.hit.true ? 'true' : (statement.hit.false ? 'false' : 'never');
557 for (let i = statement.loc.start.column; i < statement.loc.end.column; ++i) {
558 mask[i] = issue;
559 }
560 });
561
562 const chunks = [];
563
564 let from = 0;
565 for (let i = 1; i < mask.length; ++i) {
566 if (mask[i] !== mask[i - 1]) {
567 chunks.push({ source: line.slice(from, i), miss: mask[i - 1], column: from });
568 from = i;
569 }
570 }
571
572 chunks.push({ source: line.slice(from), miss: mask[from], column: from });
573
574 if (isMiss) {
575 ret.source[num].chunks = chunks;
576 ret.misses++;
577 }
578 else {
579 ret.hits++;
580 }
581 }
582 else if (!data.commentedLines[num] && line.trim()) {
583 // Only increment hits if the current line isn't blank and commented
584 ret.hits++;
585 }
586 }
587
588 if (smc) {
589 internals.addSourceMapsInformation(smc, ret, num);
590 }
591
592 ret.source[num].hits = data.lines[num];
593 ret.source[num].miss = isMiss;
594 });
595
596 ret.percent = ret.hits / ret.sloc * 100;
597 return ret;
598};