| 1 | /*
|
|---|
| 2 | Copyright 2012-2015, Yahoo Inc.
|
|---|
| 3 | Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
|
|---|
| 4 | */
|
|---|
| 5 | 'use strict';
|
|---|
| 6 |
|
|---|
| 7 | const percent = require('./percent');
|
|---|
| 8 | const dataProperties = require('./data-properties');
|
|---|
| 9 | const { CoverageSummary } = require('./coverage-summary');
|
|---|
| 10 |
|
|---|
| 11 | // returns a data object that represents empty coverage
|
|---|
| 12 | function emptyCoverage(filePath, reportLogic) {
|
|---|
| 13 | const cov = {
|
|---|
| 14 | path: filePath,
|
|---|
| 15 | statementMap: {},
|
|---|
| 16 | fnMap: {},
|
|---|
| 17 | branchMap: {},
|
|---|
| 18 | s: {},
|
|---|
| 19 | f: {},
|
|---|
| 20 | b: {}
|
|---|
| 21 | };
|
|---|
| 22 | if (reportLogic) cov.bT = {};
|
|---|
| 23 | return cov;
|
|---|
| 24 | }
|
|---|
| 25 |
|
|---|
| 26 | // asserts that a data object "looks like" a coverage object
|
|---|
| 27 | function assertValidObject(obj) {
|
|---|
| 28 | const valid =
|
|---|
| 29 | obj &&
|
|---|
| 30 | obj.path &&
|
|---|
| 31 | obj.statementMap &&
|
|---|
| 32 | obj.fnMap &&
|
|---|
| 33 | obj.branchMap &&
|
|---|
| 34 | obj.s &&
|
|---|
| 35 | obj.f &&
|
|---|
| 36 | obj.b;
|
|---|
| 37 | if (!valid) {
|
|---|
| 38 | throw new Error(
|
|---|
| 39 | 'Invalid file coverage object, missing keys, found:' +
|
|---|
| 40 | Object.keys(obj).join(',')
|
|---|
| 41 | );
|
|---|
| 42 | }
|
|---|
| 43 | }
|
|---|
| 44 |
|
|---|
| 45 | const keyFromLoc = ({ start, end }) =>
|
|---|
| 46 | `${start.line}|${start.column}|${end.line}|${end.column}`;
|
|---|
| 47 |
|
|---|
| 48 | const isObj = o => !!o && typeof o === 'object';
|
|---|
| 49 | const isLineCol = o =>
|
|---|
| 50 | isObj(o) && typeof o.line === 'number' && typeof o.column === 'number';
|
|---|
| 51 | const isLoc = o => isObj(o) && isLineCol(o.start) && isLineCol(o.end);
|
|---|
| 52 | const getLoc = o => (isLoc(o) ? o : isLoc(o.loc) ? o.loc : null);
|
|---|
| 53 |
|
|---|
| 54 | // When merging, we can have a case where two ranges cover
|
|---|
| 55 | // the same block of code with `hits=1`, and each carve out a
|
|---|
| 56 | // different range with `hits=0` to indicate it's uncovered.
|
|---|
| 57 | // Find the nearest container so that we can properly indicate
|
|---|
| 58 | // that both sections are hit.
|
|---|
| 59 | // Returns null if no containing item is found.
|
|---|
| 60 | const findNearestContainer = (item, map) => {
|
|---|
| 61 | const itemLoc = getLoc(item);
|
|---|
| 62 | if (!itemLoc) return null;
|
|---|
| 63 | // the B item is not an identified range in the A set, BUT
|
|---|
| 64 | // it may be contained by an identified A range. If so, then
|
|---|
| 65 | // any hit of that containing A range counts as a hit of this
|
|---|
| 66 | // B range as well. We have to find the *narrowest* containing
|
|---|
| 67 | // range to be accurate, since ranges can be hit and un-hit
|
|---|
| 68 | // in a nested fashion.
|
|---|
| 69 | let nearestContainingItem = null;
|
|---|
| 70 | let containerDistance = null;
|
|---|
| 71 | let containerKey = null;
|
|---|
| 72 | for (const [i, mapItem] of Object.entries(map)) {
|
|---|
| 73 | const mapLoc = getLoc(mapItem);
|
|---|
| 74 | if (!mapLoc) continue;
|
|---|
| 75 | // contained if all of line distances are > 0
|
|---|
| 76 | // or line distance is 0 and col dist is >= 0
|
|---|
| 77 | const distance = [
|
|---|
| 78 | itemLoc.start.line - mapLoc.start.line,
|
|---|
| 79 | itemLoc.start.column - mapLoc.start.column,
|
|---|
| 80 | mapLoc.end.line - itemLoc.end.line,
|
|---|
| 81 | mapLoc.end.column - itemLoc.end.column
|
|---|
| 82 | ];
|
|---|
| 83 | if (
|
|---|
| 84 | distance[0] < 0 ||
|
|---|
| 85 | distance[2] < 0 ||
|
|---|
| 86 | (distance[0] === 0 && distance[1] < 0) ||
|
|---|
| 87 | (distance[2] === 0 && distance[3] < 0)
|
|---|
| 88 | ) {
|
|---|
| 89 | continue;
|
|---|
| 90 | }
|
|---|
| 91 | if (nearestContainingItem === null) {
|
|---|
| 92 | containerDistance = distance;
|
|---|
| 93 | nearestContainingItem = mapItem;
|
|---|
| 94 | containerKey = i;
|
|---|
| 95 | continue;
|
|---|
| 96 | }
|
|---|
| 97 | // closer line more relevant than closer column
|
|---|
| 98 | const closerBefore =
|
|---|
| 99 | distance[0] < containerDistance[0] ||
|
|---|
| 100 | (distance[0] === 0 && distance[1] < containerDistance[1]);
|
|---|
| 101 | const closerAfter =
|
|---|
| 102 | distance[2] < containerDistance[2] ||
|
|---|
| 103 | (distance[2] === 0 && distance[3] < containerDistance[3]);
|
|---|
| 104 | if (closerBefore || closerAfter) {
|
|---|
| 105 | // closer
|
|---|
| 106 | containerDistance = distance;
|
|---|
| 107 | nearestContainingItem = mapItem;
|
|---|
| 108 | containerKey = i;
|
|---|
| 109 | }
|
|---|
| 110 | }
|
|---|
| 111 | return containerKey;
|
|---|
| 112 | };
|
|---|
| 113 |
|
|---|
| 114 | // either add two numbers, or all matching entries in a number[]
|
|---|
| 115 | const addHits = (aHits, bHits) => {
|
|---|
| 116 | if (typeof aHits === 'number' && typeof bHits === 'number') {
|
|---|
| 117 | return aHits + bHits;
|
|---|
| 118 | } else if (Array.isArray(aHits) && Array.isArray(bHits)) {
|
|---|
| 119 | return aHits.map((a, i) => (a || 0) + (bHits[i] || 0));
|
|---|
| 120 | }
|
|---|
| 121 | return null;
|
|---|
| 122 | };
|
|---|
| 123 |
|
|---|
| 124 | const addNearestContainerHits = (item, itemHits, map, mapHits) => {
|
|---|
| 125 | const container = findNearestContainer(item, map);
|
|---|
| 126 | if (container) {
|
|---|
| 127 | return addHits(itemHits, mapHits[container]);
|
|---|
| 128 | } else {
|
|---|
| 129 | return itemHits;
|
|---|
| 130 | }
|
|---|
| 131 | };
|
|---|
| 132 |
|
|---|
| 133 | const mergeProp = (aHits, aMap, bHits, bMap, itemKey = keyFromLoc) => {
|
|---|
| 134 | const aItems = {};
|
|---|
| 135 | for (const [key, itemHits] of Object.entries(aHits)) {
|
|---|
| 136 | const item = aMap[key];
|
|---|
| 137 | aItems[itemKey(item)] = [itemHits, item];
|
|---|
| 138 | }
|
|---|
| 139 | const bItems = {};
|
|---|
| 140 | for (const [key, itemHits] of Object.entries(bHits)) {
|
|---|
| 141 | const item = bMap[key];
|
|---|
| 142 | bItems[itemKey(item)] = [itemHits, item];
|
|---|
| 143 | }
|
|---|
| 144 | const mergedItems = {};
|
|---|
| 145 | for (const [key, aValue] of Object.entries(aItems)) {
|
|---|
| 146 | let aItemHits = aValue[0];
|
|---|
| 147 | const aItem = aValue[1];
|
|---|
| 148 | const bValue = bItems[key];
|
|---|
| 149 | if (!bValue) {
|
|---|
| 150 | // not an identified range in b, but might be contained by one
|
|---|
| 151 | aItemHits = addNearestContainerHits(aItem, aItemHits, bMap, bHits);
|
|---|
| 152 | } else {
|
|---|
| 153 | // is an identified range in b, so add the hits together
|
|---|
| 154 | aItemHits = addHits(aItemHits, bValue[0]);
|
|---|
| 155 | }
|
|---|
| 156 | mergedItems[key] = [aItemHits, aItem];
|
|---|
| 157 | }
|
|---|
| 158 | // now find the items in b that are not in a. already added matches.
|
|---|
| 159 | for (const [key, bValue] of Object.entries(bItems)) {
|
|---|
| 160 | let bItemHits = bValue[0];
|
|---|
| 161 | const bItem = bValue[1];
|
|---|
| 162 | if (mergedItems[key]) continue;
|
|---|
| 163 | // not an identified range in b, but might be contained by one
|
|---|
| 164 | bItemHits = addNearestContainerHits(bItem, bItemHits, aMap, aHits);
|
|---|
| 165 | mergedItems[key] = [bItemHits, bItem];
|
|---|
| 166 | }
|
|---|
| 167 |
|
|---|
| 168 | const hits = {};
|
|---|
| 169 | const map = {};
|
|---|
| 170 |
|
|---|
| 171 | Object.values(mergedItems).forEach(([itemHits, item], i) => {
|
|---|
| 172 | hits[i] = itemHits;
|
|---|
| 173 | map[i] = item;
|
|---|
| 174 | });
|
|---|
| 175 |
|
|---|
| 176 | return [hits, map];
|
|---|
| 177 | };
|
|---|
| 178 |
|
|---|
| 179 | /**
|
|---|
| 180 | * provides a read-only view of coverage for a single file.
|
|---|
| 181 | * The deep structure of this object is documented elsewhere. It has the following
|
|---|
| 182 | * properties:
|
|---|
| 183 | *
|
|---|
| 184 | * * `path` - the file path for which coverage is being tracked
|
|---|
| 185 | * * `statementMap` - map of statement locations keyed by statement index
|
|---|
| 186 | * * `fnMap` - map of function metadata keyed by function index
|
|---|
| 187 | * * `branchMap` - map of branch metadata keyed by branch index
|
|---|
| 188 | * * `s` - hit counts for statements
|
|---|
| 189 | * * `f` - hit count for functions
|
|---|
| 190 | * * `b` - hit count for branches
|
|---|
| 191 | */
|
|---|
| 192 | class FileCoverage {
|
|---|
| 193 | /**
|
|---|
| 194 | * @constructor
|
|---|
| 195 | * @param {Object|FileCoverage|String} pathOrObj is a string that initializes
|
|---|
| 196 | * and empty coverage object with the specified file path or a data object that
|
|---|
| 197 | * has all the required properties for a file coverage object.
|
|---|
| 198 | */
|
|---|
| 199 | constructor(pathOrObj, reportLogic = false) {
|
|---|
| 200 | if (!pathOrObj) {
|
|---|
| 201 | throw new Error(
|
|---|
| 202 | 'Coverage must be initialized with a path or an object'
|
|---|
| 203 | );
|
|---|
| 204 | }
|
|---|
| 205 | if (typeof pathOrObj === 'string') {
|
|---|
| 206 | this.data = emptyCoverage(pathOrObj, reportLogic);
|
|---|
| 207 | } else if (pathOrObj instanceof FileCoverage) {
|
|---|
| 208 | this.data = pathOrObj.data;
|
|---|
| 209 | } else if (typeof pathOrObj === 'object') {
|
|---|
| 210 | this.data = pathOrObj;
|
|---|
| 211 | } else {
|
|---|
| 212 | throw new Error('Invalid argument to coverage constructor');
|
|---|
| 213 | }
|
|---|
| 214 | assertValidObject(this.data);
|
|---|
| 215 | }
|
|---|
| 216 |
|
|---|
| 217 | /**
|
|---|
| 218 | * returns computed line coverage from statement coverage.
|
|---|
| 219 | * This is a map of hits keyed by line number in the source.
|
|---|
| 220 | */
|
|---|
| 221 | getLineCoverage() {
|
|---|
| 222 | const statementMap = this.data.statementMap;
|
|---|
| 223 | const statements = this.data.s;
|
|---|
| 224 | const lineMap = Object.create(null);
|
|---|
| 225 |
|
|---|
| 226 | Object.entries(statements).forEach(([st, count]) => {
|
|---|
| 227 | /* istanbul ignore if: is this even possible? */
|
|---|
| 228 | if (!statementMap[st]) {
|
|---|
| 229 | return;
|
|---|
| 230 | }
|
|---|
| 231 | const { line } = statementMap[st].start;
|
|---|
| 232 | const prevVal = lineMap[line];
|
|---|
| 233 | if (prevVal === undefined || prevVal < count) {
|
|---|
| 234 | lineMap[line] = count;
|
|---|
| 235 | }
|
|---|
| 236 | });
|
|---|
| 237 | return lineMap;
|
|---|
| 238 | }
|
|---|
| 239 |
|
|---|
| 240 | /**
|
|---|
| 241 | * returns an array of uncovered line numbers.
|
|---|
| 242 | * @returns {Array} an array of line numbers for which no hits have been
|
|---|
| 243 | * collected.
|
|---|
| 244 | */
|
|---|
| 245 | getUncoveredLines() {
|
|---|
| 246 | const lc = this.getLineCoverage();
|
|---|
| 247 | const ret = [];
|
|---|
| 248 | Object.entries(lc).forEach(([l, hits]) => {
|
|---|
| 249 | if (hits === 0) {
|
|---|
| 250 | ret.push(l);
|
|---|
| 251 | }
|
|---|
| 252 | });
|
|---|
| 253 | return ret;
|
|---|
| 254 | }
|
|---|
| 255 |
|
|---|
| 256 | /**
|
|---|
| 257 | * returns a map of branch coverage by source line number.
|
|---|
| 258 | * @returns {Object} an object keyed by line number. Each object
|
|---|
| 259 | * has a `covered`, `total` and `coverage` (percentage) property.
|
|---|
| 260 | */
|
|---|
| 261 | getBranchCoverageByLine() {
|
|---|
| 262 | const branchMap = this.branchMap;
|
|---|
| 263 | const branches = this.b;
|
|---|
| 264 | const ret = {};
|
|---|
| 265 | Object.entries(branchMap).forEach(([k, map]) => {
|
|---|
| 266 | const line = map.line || map.loc.start.line;
|
|---|
| 267 | const branchData = branches[k];
|
|---|
| 268 | ret[line] = ret[line] || [];
|
|---|
| 269 | ret[line].push(...branchData);
|
|---|
| 270 | });
|
|---|
| 271 | Object.entries(ret).forEach(([k, dataArray]) => {
|
|---|
| 272 | const covered = dataArray.filter(item => item > 0);
|
|---|
| 273 | const coverage = (covered.length / dataArray.length) * 100;
|
|---|
| 274 | ret[k] = {
|
|---|
| 275 | covered: covered.length,
|
|---|
| 276 | total: dataArray.length,
|
|---|
| 277 | coverage
|
|---|
| 278 | };
|
|---|
| 279 | });
|
|---|
| 280 | return ret;
|
|---|
| 281 | }
|
|---|
| 282 |
|
|---|
| 283 | /**
|
|---|
| 284 | * return a JSON-serializable POJO for this file coverage object
|
|---|
| 285 | */
|
|---|
| 286 | toJSON() {
|
|---|
| 287 | return this.data;
|
|---|
| 288 | }
|
|---|
| 289 |
|
|---|
| 290 | /**
|
|---|
| 291 | * merges a second coverage object into this one, updating hit counts
|
|---|
| 292 | * @param {FileCoverage} other - the coverage object to be merged into this one.
|
|---|
| 293 | * Note that the other object should have the same structure as this one (same file).
|
|---|
| 294 | */
|
|---|
| 295 | merge(other) {
|
|---|
| 296 | if (other.all === true) {
|
|---|
| 297 | return;
|
|---|
| 298 | }
|
|---|
| 299 |
|
|---|
| 300 | if (this.all === true) {
|
|---|
| 301 | this.data = other.data;
|
|---|
| 302 | return;
|
|---|
| 303 | }
|
|---|
| 304 |
|
|---|
| 305 | let [hits, map] = mergeProp(
|
|---|
| 306 | this.s,
|
|---|
| 307 | this.statementMap,
|
|---|
| 308 | other.s,
|
|---|
| 309 | other.statementMap
|
|---|
| 310 | );
|
|---|
| 311 | this.data.s = hits;
|
|---|
| 312 | this.data.statementMap = map;
|
|---|
| 313 |
|
|---|
| 314 | const keyFromLocProp = x => keyFromLoc(x.loc);
|
|---|
| 315 | const keyFromLocationsProp = x => keyFromLoc(x.locations[0]);
|
|---|
| 316 |
|
|---|
| 317 | [hits, map] = mergeProp(
|
|---|
| 318 | this.f,
|
|---|
| 319 | this.fnMap,
|
|---|
| 320 | other.f,
|
|---|
| 321 | other.fnMap,
|
|---|
| 322 | keyFromLocProp
|
|---|
| 323 | );
|
|---|
| 324 | this.data.f = hits;
|
|---|
| 325 | this.data.fnMap = map;
|
|---|
| 326 |
|
|---|
| 327 | [hits, map] = mergeProp(
|
|---|
| 328 | this.b,
|
|---|
| 329 | this.branchMap,
|
|---|
| 330 | other.b,
|
|---|
| 331 | other.branchMap,
|
|---|
| 332 | keyFromLocationsProp
|
|---|
| 333 | );
|
|---|
| 334 | this.data.b = hits;
|
|---|
| 335 | this.data.branchMap = map;
|
|---|
| 336 |
|
|---|
| 337 | // Tracking additional information about branch truthiness
|
|---|
| 338 | // can be optionally enabled:
|
|---|
| 339 | if (this.bT && other.bT) {
|
|---|
| 340 | [hits, map] = mergeProp(
|
|---|
| 341 | this.bT,
|
|---|
| 342 | this.branchMap,
|
|---|
| 343 | other.bT,
|
|---|
| 344 | other.branchMap,
|
|---|
| 345 | keyFromLocationsProp
|
|---|
| 346 | );
|
|---|
| 347 | this.data.bT = hits;
|
|---|
| 348 | }
|
|---|
| 349 | }
|
|---|
| 350 |
|
|---|
| 351 | computeSimpleTotals(property) {
|
|---|
| 352 | let stats = this[property];
|
|---|
| 353 |
|
|---|
| 354 | if (typeof stats === 'function') {
|
|---|
| 355 | stats = stats.call(this);
|
|---|
| 356 | }
|
|---|
| 357 |
|
|---|
| 358 | const ret = {
|
|---|
| 359 | total: Object.keys(stats).length,
|
|---|
| 360 | covered: Object.values(stats).filter(v => !!v).length,
|
|---|
| 361 | skipped: 0
|
|---|
| 362 | };
|
|---|
| 363 | ret.pct = percent(ret.covered, ret.total);
|
|---|
| 364 | return ret;
|
|---|
| 365 | }
|
|---|
| 366 |
|
|---|
| 367 | computeBranchTotals(property) {
|
|---|
| 368 | const stats = this[property];
|
|---|
| 369 | const ret = { total: 0, covered: 0, skipped: 0 };
|
|---|
| 370 |
|
|---|
| 371 | Object.values(stats).forEach(branches => {
|
|---|
| 372 | ret.covered += branches.filter(hits => hits > 0).length;
|
|---|
| 373 | ret.total += branches.length;
|
|---|
| 374 | });
|
|---|
| 375 | ret.pct = percent(ret.covered, ret.total);
|
|---|
| 376 | return ret;
|
|---|
| 377 | }
|
|---|
| 378 |
|
|---|
| 379 | /**
|
|---|
| 380 | * resets hit counts for all statements, functions and branches
|
|---|
| 381 | * in this coverage object resulting in zero coverage.
|
|---|
| 382 | */
|
|---|
| 383 | resetHits() {
|
|---|
| 384 | const statements = this.s;
|
|---|
| 385 | const functions = this.f;
|
|---|
| 386 | const branches = this.b;
|
|---|
| 387 | const branchesTrue = this.bT;
|
|---|
| 388 | Object.keys(statements).forEach(s => {
|
|---|
| 389 | statements[s] = 0;
|
|---|
| 390 | });
|
|---|
| 391 | Object.keys(functions).forEach(f => {
|
|---|
| 392 | functions[f] = 0;
|
|---|
| 393 | });
|
|---|
| 394 | Object.keys(branches).forEach(b => {
|
|---|
| 395 | branches[b].fill(0);
|
|---|
| 396 | });
|
|---|
| 397 | // Tracking additional information about branch truthiness
|
|---|
| 398 | // can be optionally enabled:
|
|---|
| 399 | if (branchesTrue) {
|
|---|
| 400 | Object.keys(branchesTrue).forEach(bT => {
|
|---|
| 401 | branchesTrue[bT].fill(0);
|
|---|
| 402 | });
|
|---|
| 403 | }
|
|---|
| 404 | }
|
|---|
| 405 |
|
|---|
| 406 | /**
|
|---|
| 407 | * returns a CoverageSummary for this file coverage object
|
|---|
| 408 | * @returns {CoverageSummary}
|
|---|
| 409 | */
|
|---|
| 410 | toSummary() {
|
|---|
| 411 | const ret = {};
|
|---|
| 412 | ret.lines = this.computeSimpleTotals('getLineCoverage');
|
|---|
| 413 | ret.functions = this.computeSimpleTotals('f', 'fnMap');
|
|---|
| 414 | ret.statements = this.computeSimpleTotals('s', 'statementMap');
|
|---|
| 415 | ret.branches = this.computeBranchTotals('b');
|
|---|
| 416 | // Tracking additional information about branch truthiness
|
|---|
| 417 | // can be optionally enabled:
|
|---|
| 418 | if (this.bT) {
|
|---|
| 419 | ret.branchesTrue = this.computeBranchTotals('bT');
|
|---|
| 420 | }
|
|---|
| 421 | return new CoverageSummary(ret);
|
|---|
| 422 | }
|
|---|
| 423 | }
|
|---|
| 424 |
|
|---|
| 425 | // expose coverage data attributes
|
|---|
| 426 | dataProperties(FileCoverage, [
|
|---|
| 427 | 'path',
|
|---|
| 428 | 'statementMap',
|
|---|
| 429 | 'fnMap',
|
|---|
| 430 | 'branchMap',
|
|---|
| 431 | 's',
|
|---|
| 432 | 'f',
|
|---|
| 433 | 'b',
|
|---|
| 434 | 'bT',
|
|---|
| 435 | 'all'
|
|---|
| 436 | ]);
|
|---|
| 437 |
|
|---|
| 438 | module.exports = {
|
|---|
| 439 | FileCoverage,
|
|---|
| 440 | // exported for testing
|
|---|
| 441 | findNearestContainer,
|
|---|
| 442 | addHits,
|
|---|
| 443 | addNearestContainerHits
|
|---|
| 444 | };
|
|---|