UNPKG

16.2 kBJavaScriptView Raw
1import { createHash } from 'crypto';
2import { util } from 'babel-core';
3import prelude from './prelude';
4import meta from './meta';
5import { applyRules, addRules } from './tags';
6
7export function hash(code) {
8 return createHash('sha1').update(code).digest('hex');
9}
10
11export function skip({ opts, file } = { }) {
12 if (file && opts) {
13 const { ignore = [], only } = opts;
14 return util.shouldIgnore(
15 file.opts.filename,
16 util.arrayify(ignore, util.regexify),
17 only ? util.arrayify(only, util.regexify) : null
18 );
19 }
20 return false;
21}
22
23/**
24 * Create an opaque, unique key for a given node. Useful for tagging the node
25 * in separate places.
26 * @param {Object} path Babel path to derive key from.
27 * @returns {String} String key.
28 */
29export function key(path) {
30 const node = path.node;
31 if (node.loc) {
32 const location = node.loc.start;
33 return `${location.line}:${location.column}`;
34 }
35 throw new TypeError('Path must have valid location.');
36}
37
38/**
39 * Some nodes need to marked as non-instrumentable; since babel will apply
40 * our plugin to nodes we create, we have to be careful to not put ourselves
41 * into an infinite loop.
42 * @param {Object} node Babel AST node.
43 * @returns {Object} Babel AST node that won't be instrumented.
44 */
45function X(node) {
46 node.__adana = true;
47 return node;
48}
49
50function ignore(path) {
51 return (!path.node || !path.node.loc || path.node.__adana);
52}
53
54function standardize(listener) {
55 return (path, state) => ignore(path) ? undefined : listener(path, state);
56}
57
58/**
59 * Create the transform-adana babel plugin.
60 * @param {Object} types As per `babel`.
61 * @returns {Object} `babel` plugin object.
62 */
63export default function adana({ types }) {
64 /**
65 * Create a chunk of code that marks the specified node as having
66 * been executed.
67 * @param {Object} state `babel` state for the path that's being walked.
68 * @param {Object} options Configure how the marker behaves.
69 * @returns {Object} AST node for marking coverage.
70 */
71 function createMarker(state, options) {
72 const { tags, loc, name, group } = options;
73 const coverage = meta(state);
74 const id = coverage.entries.length;
75
76 coverage.entries.push({
77 id,
78 loc,
79 tags,
80 name,
81 group,
82 count: 0,
83 });
84
85 // Maker is simply a statement incrementing a coverage variable.
86 return X(types.unaryExpression('++', types.memberExpression(
87 types.memberExpression(
88 coverage.variable,
89 types.numericLiteral(id),
90 true
91 ),
92 types.stringLiteral('count'),
93 true
94 )));
95 }
96
97 /**
98 * [isInstrumentableStatement description]
99 * @param {[type]} path [description]
100 * @returns {Boolean} [description]
101 */
102 function isInstrumentableStatement(path) {
103 const parent = path.parentPath;
104 return !parent.isReturnStatement() &&
105 !parent.isVariableDeclaration() &&
106 !parent.isExportDeclaration() &&
107 !parent.isFunctionDeclaration() &&
108 !parent.isIfStatement();
109 }
110
111 /**
112 * Inject a marker that measures whether the node for the given path has
113 * been run or not.
114 * @param {Object} path [description]
115 * @param {Object} state [description]
116 * @param {Object} options [description]
117 * @returns {void}
118 */
119 function instrument(path, state, options) {
120 // This function is here because isInstrumentableStatement() is being
121 // called; we can't create the marker without knowing the result of that,
122 // otherwise dead markers will be created.
123 function marker() {
124 return createMarker(state, {
125 loc: path.node.loc,
126 ...options,
127 });
128 }
129
130 if (path.isBlockStatement()) {
131 path.unshiftContainer('body', X(types.expressionStatement(marker())));
132 } else if (path.isExpression()) {
133 path.replaceWith(X(types.sequenceExpression([ marker(), path.node ])));
134 } else if (path.isStatement()) {
135 if (isInstrumentableStatement(path)) {
136 path.insertBefore(X(types.expressionStatement(marker())));
137 }
138 }
139 }
140
141 /**
142 * [visitStatement description]
143 * @param {[type]} path [description]
144 * @param {[type]} state [description]
145 * @returns {void}
146 */
147 function visitStatement(path, state) {
148 instrument(path, state, {
149 tags: [ 'statement', 'line' ],
150 loc: path.node.loc,
151 });
152 }
153
154 /**
155 * The function visitor is mainly to track the definitions of functions;
156 * being able ensure how many of your functions have actually been invoked.
157 * @param {[type]} path [description]
158 * @param {[type]} state [description]
159 * @returns {void}
160 */
161 function visitFunction(path, state) {
162 instrument(path.get('body'), state, {
163 tags: [ 'function' ],
164 name: path.node.id ? path.node.id.name : `@${key(path)}`,
165 loc: path.node.loc,
166 });
167 }
168
169 /**
170 * Multiple branches based on the result of `case _` and `default`. If you
171 * do not provide a `default` one will be intelligently added for you,
172 * forcing you to cover that case.
173 * @param {[type]} path [description]
174 * @param {[type]} state [description]
175 * @returns {void}
176 */
177 function visitSwitchStatement(path, state) {
178 let hasDefault = false;
179 path.get('cases').forEach(entry => {
180 if (entry.node.test) {
181 addRules(state, entry.node.loc, entry.node.test.trailingComments);
182 }
183 if (entry.node.consequent.length > 1) {
184 addRules(
185 state,
186 entry.node.loc,
187 entry.node.consequent[0].leadingComments
188 );
189 }
190
191 if (entry.node.test === null) {
192 hasDefault = true;
193 }
194 entry.unshiftContainer('consequent', createMarker(state, {
195 tags: [ 'branch' ],
196 loc: entry.node.loc,
197 group: key(path),
198 }));
199 });
200
201 // Default is technically a branch, just like if statements without
202 // else's are also technically a branch.
203 if (!hasDefault) {
204 // Add an extra break to the end of the last case in case some idiot
205 // forgot to add it.
206 const cases = path.get('cases');
207 if (cases.length > 0) {
208 cases[cases.length - 1].pushContainer(
209 'consequent',
210 types.breakStatement()
211 );
212 }
213 // Finally add the default case.
214 path.pushContainer('cases', types.switchCase(null, [
215 types.expressionStatement(createMarker(state, {
216 tags: [ 'branch' ],
217 loc: {
218 start: path.node.loc.end,
219 end: path.node.loc.end,
220 },
221 group: key(path),
222 })),
223 types.breakStatement(),
224 ]));
225 }
226 }
227
228 /**
229 * [visitVariableDeclaration description]
230 * @param {[type]} path [description]
231 * @param {[type]} state [description]
232 * @returns {void}
233 */
234 function visitVariableDeclaration(path, state) {
235 path.get('declarations').forEach(decl => {
236 if (decl.has('init')) {
237 instrument(decl.get('init'), state, {
238 tags: [ 'statement', 'variable', 'line' ],
239 });
240 }
241 });
242 }
243
244 /**
245 * Includes both while and do-while loops. They contain a single branch which
246 * tests the loop condition.
247 * @param {[type]} path [description]
248 * @param {[type]} state [description]
249 * @returns {void}
250 */
251 function visitWhileLoop(path, state) {
252 const test = path.get('test');
253 const group = key(path);
254 // This is a particularly clever use of the fact JS operators are short-
255 // circuiting. To instrument a loop one _cannot_ add a marker on the outside
256 // of the loop body due to weird cases of things where loops are in non-
257 // block if statements. So instead, create the following mechanism:
258 // ((condition && A) || !B) where A and B are markers. Since markers are
259 // postfix, they're always true. Ergo, A is only incremented when condition
260 // is true, B only when it's false and the truth value of the whole
261 // statement is preserved. Neato.
262 test.replaceWith(types.logicalExpression(
263 '||',
264 types.logicalExpression(
265 '&&',
266 X(test.node),
267 createMarker(state, {
268 tags: [ 'branch', 'line', 'statement' ],
269 loc: test.node.loc,
270 group,
271 })
272 ),
273 types.unaryExpression(
274 '!',
275 createMarker(state, {
276 tags: [ 'branch', 'line' ],
277 loc: test.node.loc,
278 group,
279 })
280 )
281 ));
282 }
283
284 /**
285 * The try block can either fully succeed (no error) or it can throw. Both
286 * cases are accounted for.
287 * @param {[type]} path [description]
288 * @param {[type]} state [description]
289 * @returns {void}
290 */
291 function visitTryStatement(path, state) {
292 const group = key(path);
293 path.get('block').pushContainer('body', types.expressionStatement(
294 createMarker(state, {
295 tags: [ 'branch', 'line' ],
296 loc: path.get('block').node.loc,
297 group,
298 })
299 ));
300 if (path.has('handler')) {
301 path.get('handler.body').unshiftContainer(
302 'body',
303 types.expressionStatement(
304 createMarker(state, {
305 tags: [ 'branch', 'line' ],
306 loc: path.get('handler').node.loc,
307 group,
308 })
309 )
310 );
311 } else {
312 const loc = path.get('block').node.loc.end;
313 path.get('handler').replaceWith(types.catchClause(
314 types.identifier('err'), types.blockStatement([
315 types.expressionStatement(
316 createMarker(state, {
317 tags: [ 'branch' ],
318 loc: {
319 start: loc,
320 end: loc,
321 },
322 group,
323 })
324 ),
325 types.throwStatement(
326 types.identifier('err')
327 ),
328 ])
329 ));
330 }
331 }
332
333 /**
334 * Return statements are instrumented by marking the next block they return.
335 * This helps ensure multi-line expressions for return statements are
336 * accurately captured.
337 * @param {[type]} path [description]
338 * @param {[type]} state [description]
339 * @returns {[type]} [description]
340 */
341 function visitReturnStatement(path, state) {
342 if (!path.has('argument')) {
343 path.get('argument').replaceWith(types.sequenceExpression([
344 createMarker(state, {
345 loc: path.node.loc,
346 tags: [ 'line', 'statement' ],
347 }),
348 types.identifier('undefined'),
349 ]));
350 } else {
351 instrument(path.get('argument'), state, {
352 tags: [ 'line', 'statement' ],
353 });
354 }
355 }
356
357 /**
358 * For multi-line reporting (and objects do tend to span multiple lines) this
359 * is required to know which parts of the object where actually executed.
360 * Ignore shorthand property that look like `{ this }`.
361 * @param {[type]} path [description]
362 * @param {[type]} state [description]
363 * @returns {[type]} [description]
364 */
365 function visitObjectProperty(path, state) {
366 if (!path.node.shorthand && !path.parentPath.isPattern()) {
367 const key = path.get('key');
368 const value = path.get('value');
369 if (path.node.computed) {
370 instrument(key, state, {
371 tags: [ 'line' ],
372 });
373 }
374 instrument(value, state, {
375 tags: [ 'line' ],
376 });
377 }
378 }
379
380 /**
381 * For multi-line reporting (and arrays do tend to span multiple lines) this
382 * is required to know which parts of the array where actually executed.
383 * This does _not_ include destructed arrays.
384 * @param {[type]} path [description]
385 * @param {[type]} state [description]
386 * @returns {[type]} [description]
387 */
388 function visitArrayExpression(path, state) {
389 if (!path.parentPath.isPattern()) {
390 path.get('elements').forEach(element => {
391 instrument(element, state, {
392 tags: [ 'line' ],
393 });
394 });
395 }
396 }
397
398 /**
399 * Logical expressions are those using logic operators like `&&` and `||`.
400 * Since logic expressions short-circuit in JS they are effectively branches
401 * and will be treated as such here.
402 * @param {[type]} path [description]
403 * @param {[type]} state [description]
404 * @returns {void}
405 */
406 function visitLogicalExpression(path, state) {
407 const group = key(path);
408 const test = path.scope.generateDeclaredUidIdentifier('test');
409
410 path.replaceWith(X(types.conditionalExpression(
411 types.assignmentExpression('=', test, X(path.node)),
412 types.sequenceExpression([ createMarker(state, {
413 tags: [ 'branch' ],
414 loc: path.get('left').node.loc,
415 group,
416 }), test ]),
417 types.sequenceExpression([ createMarker(state, {
418 tags: [ 'branch' ],
419 loc: path.get('right').node.loc,
420 group,
421 }), test ])
422 )));
423 }
424
425 /**
426 * Conditionals are either if/else statements or tenaiary expressions. They
427 * have a test case and two choices (based on the test result). Both cases
428 * are always accounted for, even if the code does not exist for the alternate
429 * case.
430 * @param {[type]} path [description]
431 * @param {[type]} state [description]
432 * @returns {void}
433 */
434 function visitConditional(path, state) {
435 // Branches can be grouped together so that each of the possible branch
436 // destinations is accounted for under one group. For if statements, this
437 // refers to all the blocks that fall under a single if.. else if.. else..
438 // grouping.
439 const root = path.findParent(search => {
440 return search.node.type === path.node.type &&
441 !ignore(search) &&
442 (!search.parentPath || search.parentPath.node.type !== path.node.type);
443 }) || path;
444
445 // Create the group name based on the root `if` statement.
446 const group = key(root);
447
448 function tagBranch(path) {
449 addRules(state, path.node.loc, path.node.leadingComments);
450 if (path.isBlockStatement() && path.node.body.length > 0) {
451 addRules(state, path.node.loc, path.node.body[0].leadingComments);
452 }
453 }
454
455 tagBranch(path.get('consequent'));
456 if (path.has('alternate')) {
457 tagBranch(path.get('alternate'));
458 }
459
460 instrument(path.get('consequent'), state, {
461 tags: [ 'branch', 'line' ],
462 loc: path.node.consequent.loc,
463 group,
464 });
465
466 if (path.has('alternate') && !path.get('alternate').isIfStatement()) {
467 instrument(path.get('alternate'), state, {
468 tags: [ 'branch', 'line' ],
469 loc: path.node.alternate.loc,
470 group,
471 });
472 } else if (!path.has('alternate')) {
473 path.get('alternate').replaceWith(types.expressionStatement(
474 createMarker(state, {
475 tags: [ 'branch' ],
476 loc: {
477 start: path.node.loc.end,
478 end: path.node.loc.end,
479 },
480 group,
481 }))
482 );
483 }
484 }
485
486 const visitor = {
487 // Expressions
488 ArrowFunctionExpression: visitFunction,
489 FunctionExpression: visitFunction,
490 ObjectMethod: visitFunction,
491 LogicalExpression: visitLogicalExpression,
492 ConditionalExpression: visitConditional,
493 ObjectProperty: visitObjectProperty,
494 ArrayExpression: visitArrayExpression,
495
496 // Declarations
497 FunctionDeclaration: visitFunction,
498 VariableDeclaration: visitVariableDeclaration,
499
500 // Statements
501 ContinueStatement: visitStatement,
502 BreakStatement: visitStatement,
503 ExpressionStatement: visitStatement,
504 ThrowStatement: visitStatement,
505 ReturnStatement: visitReturnStatement,
506 TryStatement: visitTryStatement,
507 WhileStatement: visitWhileLoop,
508 DoWhileStatement: visitWhileLoop,
509 IfStatement: visitConditional,
510 SwitchStatement: visitSwitchStatement,
511 };
512
513 Object.keys(visitor).forEach(key => {
514 visitor[key] = standardize(visitor[key]);
515 });
516
517 // Create the actual babel plugin object.
518 return {
519 visitor: {
520 Program(path, state) {
521 // Check if file should be instrumented or not.
522 if (skip(state)) {
523 return;
524 }
525 meta(state, {
526 hash: hash(state.file.code),
527 entries: [],
528 rules: [],
529 tags: {},
530 variable: path.scope.generateUidIdentifier('coverage'),
531 });
532 path.traverse(visitor, state);
533 applyRules(state);
534 path.unshiftContainer('body', prelude(state));
535 },
536 },
537 };
538}