• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1'use strict';
2const {
3  ArrayFrom,
4  ArrayPrototypeMap,
5  ArrayPrototypePush,
6  JSONParse,
7  MathFloor,
8  NumberParseInt,
9  RegExpPrototypeExec,
10  RegExpPrototypeSymbolSplit,
11  SafeMap,
12  SafeSet,
13  StringPrototypeIncludes,
14  StringPrototypeLocaleCompare,
15  StringPrototypeStartsWith,
16} = primordials;
17const {
18  copyFileSync,
19  mkdirSync,
20  mkdtempSync,
21  opendirSync,
22  readFileSync,
23} = require('fs');
24const { setupCoverageHooks } = require('internal/util');
25const { tmpdir } = require('os');
26const { join, resolve } = require('path');
27const { fileURLToPath } = require('url');
28const kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/;
29const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//;
30const kLineEndingRegex = /\r?\n$/u;
31const kLineSplitRegex = /(?<=\r?\n)/u;
32const kStatusRegex = /\/\* node:coverage (?<status>enable|disable) \*\//;
33
34class CoverageLine {
35  #covered;
36
37  constructor(line, src, startOffset) {
38    const newlineLength =
39      RegExpPrototypeExec(kLineEndingRegex, src)?.[0].length ?? 0;
40
41    this.line = line;
42    this.src = src;
43    this.startOffset = startOffset;
44    this.endOffset = startOffset + src.length - newlineLength;
45    this.ignore = false;
46    this.#covered = true;
47  }
48
49  get covered() {
50    return this.#covered;
51  }
52
53  set covered(isCovered) {
54    // V8 can generate multiple ranges that span the same line.
55    if (!this.#covered) {
56      return;
57    }
58
59    this.#covered = isCovered;
60  }
61}
62
63class TestCoverage {
64  constructor(coverageDirectory, originalCoverageDirectory, workingDirectory) {
65    this.coverageDirectory = coverageDirectory;
66    this.originalCoverageDirectory = originalCoverageDirectory;
67    this.workingDirectory = workingDirectory;
68  }
69
70  summary() {
71    internalBinding('profiler').takeCoverage();
72    const coverage = getCoverageFromDirectory(this.coverageDirectory);
73    const coverageSummary = {
74      __proto__: null,
75      workingDirectory: this.workingDirectory,
76      files: [],
77      totals: {
78        __proto__: null,
79        totalLineCount: 0,
80        totalBranchCount: 0,
81        totalFunctionCount: 0,
82        coveredLineCount: 0,
83        coveredBranchCount: 0,
84        coveredFunctionCount: 0,
85        coveredLinePercent: 0,
86        coveredBranchPercent: 0,
87        coveredFunctionPercent: 0,
88      },
89    };
90
91    if (!coverage) {
92      return coverageSummary;
93    }
94
95    for (let i = 0; i < coverage.length; ++i) {
96      const { functions, url } = coverage[i];
97
98      // Split the file source into lines. Make sure the lines maintain their
99      // original line endings because those characters are necessary for
100      // determining offsets in the file.
101      const filePath = fileURLToPath(url);
102      let source;
103
104      try {
105        source = readFileSync(filePath, 'utf8');
106      } catch {
107        // The file can no longer be read. It may have been deleted among
108        // other possibilities. Leave it out of the coverage report.
109        continue;
110      }
111
112      const linesWithBreaks =
113        RegExpPrototypeSymbolSplit(kLineSplitRegex, source);
114      let ignoreCount = 0;
115      let enabled = true;
116      let offset = 0;
117      let totalBranches = 0;
118      let totalFunctions = 0;
119      let branchesCovered = 0;
120      let functionsCovered = 0;
121
122      const lines = ArrayPrototypeMap(linesWithBreaks, (line, i) => {
123        const startOffset = offset;
124        const coverageLine = new CoverageLine(i + 1, line, startOffset);
125
126        offset += line.length;
127
128        // Determine if this line is being ignored.
129        if (ignoreCount > 0) {
130          ignoreCount--;
131          coverageLine.ignore = true;
132        } else if (!enabled) {
133          coverageLine.ignore = true;
134        }
135
136        if (!coverageLine.ignore) {
137          // If this line is not already being ignored, check for ignore
138          // comments.
139          const match = RegExpPrototypeExec(kIgnoreRegex, line);
140
141          if (match !== null) {
142            ignoreCount = NumberParseInt(match.groups?.count ?? 1, 10);
143          }
144        }
145
146        // Check for comments to enable/disable coverage no matter what. These
147        // take precedence over ignore comments.
148        const match = RegExpPrototypeExec(kStatusRegex, line);
149        const status = match?.groups?.status;
150
151        if (status) {
152          ignoreCount = 0;
153          enabled = status === 'enable';
154        }
155
156        return coverageLine;
157      });
158
159      for (let j = 0; j < functions.length; ++j) {
160        const { isBlockCoverage, ranges } = functions[j];
161
162        for (let k = 0; k < ranges.length; ++k) {
163          const range = ranges[k];
164
165          mapRangeToLines(range, lines);
166
167          if (isBlockCoverage) {
168            if (range.count !== 0 ||
169                range.ignoredLines === range.lines.length) {
170              branchesCovered++;
171            }
172
173            totalBranches++;
174          }
175        }
176
177        if (j > 0 && ranges.length > 0) {
178          const range = ranges[0];
179
180          if (range.count !== 0 || range.ignoredLines === range.lines.length) {
181            functionsCovered++;
182          }
183
184          totalFunctions++;
185        }
186      }
187
188      let coveredCnt = 0;
189      const uncoveredLineNums = [];
190
191      for (let j = 0; j < lines.length; ++j) {
192        const line = lines[j];
193
194        if (line.covered || line.ignore) {
195          coveredCnt++;
196        } else {
197          ArrayPrototypePush(uncoveredLineNums, line.line);
198        }
199      }
200
201      ArrayPrototypePush(coverageSummary.files, {
202        __proto__: null,
203        path: filePath,
204        totalLineCount: lines.length,
205        totalBranchCount: totalBranches,
206        totalFunctionCount: totalFunctions,
207        coveredLineCount: coveredCnt,
208        coveredBranchCount: branchesCovered,
209        coveredFunctionCount: functionsCovered,
210        coveredLinePercent: toPercentage(coveredCnt, lines.length),
211        coveredBranchPercent: toPercentage(branchesCovered, totalBranches),
212        coveredFunctionPercent: toPercentage(functionsCovered, totalFunctions),
213        uncoveredLineNumbers: uncoveredLineNums,
214      });
215
216      coverageSummary.totals.totalLineCount += lines.length;
217      coverageSummary.totals.totalBranchCount += totalBranches;
218      coverageSummary.totals.totalFunctionCount += totalFunctions;
219      coverageSummary.totals.coveredLineCount += coveredCnt;
220      coverageSummary.totals.coveredBranchCount += branchesCovered;
221      coverageSummary.totals.coveredFunctionCount += functionsCovered;
222    }
223
224    coverageSummary.totals.coveredLinePercent = toPercentage(
225      coverageSummary.totals.coveredLineCount,
226      coverageSummary.totals.totalLineCount,
227    );
228    coverageSummary.totals.coveredBranchPercent = toPercentage(
229      coverageSummary.totals.coveredBranchCount,
230      coverageSummary.totals.totalBranchCount,
231    );
232    coverageSummary.totals.coveredFunctionPercent = toPercentage(
233      coverageSummary.totals.coveredFunctionCount,
234      coverageSummary.totals.totalFunctionCount,
235    );
236    coverageSummary.files.sort(sortCoverageFiles);
237
238    return coverageSummary;
239  }
240
241  cleanup() {
242    // Restore the original value of process.env.NODE_V8_COVERAGE. Then, copy
243    // all of the created coverage files to the original coverage directory.
244    if (this.originalCoverageDirectory === undefined) {
245      delete process.env.NODE_V8_COVERAGE;
246      return;
247    }
248
249    process.env.NODE_V8_COVERAGE = this.originalCoverageDirectory;
250    let dir;
251
252    try {
253      mkdirSync(this.originalCoverageDirectory, { recursive: true });
254      dir = opendirSync(this.coverageDirectory);
255
256      for (let entry; (entry = dir.readSync()) !== null;) {
257        const src = join(this.coverageDirectory, entry.name);
258        const dst = join(this.originalCoverageDirectory, entry.name);
259        copyFileSync(src, dst);
260      }
261    } finally {
262      if (dir) {
263        dir.closeSync();
264      }
265    }
266  }
267}
268
269function toPercentage(covered, total) {
270  return total === 0 ? 100 : (covered / total) * 100;
271}
272
273function sortCoverageFiles(a, b) {
274  return StringPrototypeLocaleCompare(a.path, b.path);
275}
276
277function setupCoverage() {
278  let originalCoverageDirectory = process.env.NODE_V8_COVERAGE;
279  const cwd = process.cwd();
280
281  if (originalCoverageDirectory) {
282    // NODE_V8_COVERAGE was already specified. Convert it to an absolute path
283    // and store it for later. The test runner will use a temporary directory
284    // so that no preexisting coverage files interfere with the results of the
285    // coverage report. Then, once the coverage is computed, move the coverage
286    // files back to the original NODE_V8_COVERAGE directory.
287    originalCoverageDirectory = resolve(cwd, originalCoverageDirectory);
288  }
289
290  const coverageDirectory = mkdtempSync(join(tmpdir(), 'node-coverage-'));
291  const enabled = setupCoverageHooks(coverageDirectory);
292
293  if (!enabled) {
294    return null;
295  }
296
297  // Ensure that NODE_V8_COVERAGE is set so that coverage can propagate to
298  // child processes.
299  process.env.NODE_V8_COVERAGE = coverageDirectory;
300
301  return new TestCoverage(coverageDirectory, originalCoverageDirectory, cwd);
302}
303
304function mapRangeToLines(range, lines) {
305  const { startOffset, endOffset, count } = range;
306  const mappedLines = [];
307  let ignoredLines = 0;
308  let start = 0;
309  let end = lines.length;
310  let mid;
311
312  while (start <= end) {
313    mid = MathFloor((start + end) / 2);
314    let line = lines[mid];
315
316    if (startOffset >= line.startOffset && startOffset <= line.endOffset) {
317      while (endOffset > line?.startOffset) {
318        // If the range is not covered, and the range covers the entire line,
319        // then mark that line as not covered.
320        if (count === 0 && startOffset <= line.startOffset &&
321            endOffset >= line.endOffset) {
322          line.covered = false;
323        }
324
325        ArrayPrototypePush(mappedLines, line);
326
327        if (line.ignore) {
328          ignoredLines++;
329        }
330
331        mid++;
332        line = lines[mid];
333      }
334
335      break;
336    } else if (startOffset >= line.endOffset) {
337      start = mid + 1;
338    } else {
339      end = mid - 1;
340    }
341  }
342
343  // Add some useful data to the range. The test runner has read these ranges
344  // from a file, so we own the data structures and can do what we want.
345  range.lines = mappedLines;
346  range.ignoredLines = ignoredLines;
347}
348
349function getCoverageFromDirectory(coverageDirectory) {
350  const result = new SafeMap();
351  let dir;
352
353  try {
354    dir = opendirSync(coverageDirectory);
355
356    for (let entry; (entry = dir.readSync()) !== null;) {
357      if (RegExpPrototypeExec(kCoverageFileRegex, entry.name) === null) {
358        continue;
359      }
360
361      const coverageFile = join(coverageDirectory, entry.name);
362      const coverage = JSONParse(readFileSync(coverageFile, 'utf8'));
363
364      mergeCoverage(result, coverage.result);
365    }
366
367    return ArrayFrom(result.values());
368  } finally {
369    if (dir) {
370      dir.closeSync();
371    }
372  }
373}
374
375function mergeCoverage(merged, coverage) {
376  for (let i = 0; i < coverage.length; ++i) {
377    const newScript = coverage[i];
378    const { url } = newScript;
379
380    // The first part of this check filters out the node_modules/ directory
381    // from the results. This filter is applied first because most real world
382    // applications will be dominated by third party dependencies. The second
383    // part of the check filters out core modules, which start with 'node:' in
384    // coverage reports, as well as any invalid coverages which have been
385    // observed on Windows.
386    if (StringPrototypeIncludes(url, '/node_modules/') ||
387        !StringPrototypeStartsWith(url, 'file:')) {
388      continue;
389    }
390
391    const oldScript = merged.get(url);
392
393    if (oldScript === undefined) {
394      merged.set(url, newScript);
395    } else {
396      mergeCoverageScripts(oldScript, newScript);
397    }
398  }
399}
400
401function mergeCoverageScripts(oldScript, newScript) {
402  // Merge the functions from the new coverage into the functions from the
403  // existing (merged) coverage.
404  for (let i = 0; i < newScript.functions.length; ++i) {
405    const newFn = newScript.functions[i];
406    let found = false;
407
408    for (let j = 0; j < oldScript.functions.length; ++j) {
409      const oldFn = oldScript.functions[j];
410
411      if (newFn.functionName === oldFn.functionName &&
412          newFn.ranges?.[0].startOffset === oldFn.ranges?.[0].startOffset &&
413          newFn.ranges?.[0].endOffset === oldFn.ranges?.[0].endOffset) {
414        // These are the same functions.
415        found = true;
416
417        // If newFn is block level coverage, then it will:
418        // - Replace oldFn if oldFn is not block level coverage.
419        // - Merge with oldFn if it is also block level coverage.
420        // If newFn is not block level coverage, then it has no new data.
421        if (newFn.isBlockCoverage) {
422          if (oldFn.isBlockCoverage) {
423            // Merge the oldFn ranges with the newFn ranges.
424            mergeCoverageRanges(oldFn, newFn);
425          } else {
426            // Replace oldFn with newFn.
427            oldFn.isBlockCoverage = true;
428            oldFn.ranges = newFn.ranges;
429          }
430        }
431
432        break;
433      }
434    }
435
436    if (!found) {
437      // This is a new function to track. This is possible because V8 can
438      // generate a different list of functions depending on which code paths
439      // are executed. For example, if a code path dynamically creates a
440      // function, but that code path is not executed then the function does
441      // not show up in the coverage report. Unfortunately, this also means
442      // that the function counts in the coverage summary can never be
443      // guaranteed to be 100% accurate.
444      ArrayPrototypePush(oldScript.functions, newFn);
445    }
446  }
447}
448
449function mergeCoverageRanges(oldFn, newFn) {
450  const mergedRanges = new SafeSet();
451
452  // Keep all of the existing covered ranges.
453  for (let i = 0; i < oldFn.ranges.length; ++i) {
454    const oldRange = oldFn.ranges[i];
455
456    if (oldRange.count > 0) {
457      mergedRanges.add(oldRange);
458    }
459  }
460
461  // Merge in the new ranges where appropriate.
462  for (let i = 0; i < newFn.ranges.length; ++i) {
463    const newRange = newFn.ranges[i];
464    let exactMatch = false;
465
466    for (let j = 0; j < oldFn.ranges.length; ++j) {
467      const oldRange = oldFn.ranges[j];
468
469      if (doesRangeEqualOtherRange(newRange, oldRange)) {
470        // These are the same ranges, so keep the existing one.
471        oldRange.count += newRange.count;
472        mergedRanges.add(oldRange);
473        exactMatch = true;
474        break;
475      }
476
477      // Look at ranges representing missing coverage and add ranges that
478      // represent the intersection.
479      if (oldRange.count === 0 && newRange.count === 0) {
480        if (doesRangeContainOtherRange(oldRange, newRange)) {
481          // The new range is completely within the old range. Discard the
482          // larger (old) range, and keep the smaller (new) range.
483          mergedRanges.add(newRange);
484        } else if (doesRangeContainOtherRange(newRange, oldRange)) {
485          // The old range is completely within the new range. Discard the
486          // larger (new) range, and keep the smaller (old) range.
487          mergedRanges.add(oldRange);
488        }
489      }
490    }
491
492    // Add new ranges that do not represent missing coverage.
493    if (newRange.count > 0 && !exactMatch) {
494      mergedRanges.add(newRange);
495    }
496  }
497
498  oldFn.ranges = ArrayFrom(mergedRanges);
499}
500
501function doesRangeEqualOtherRange(range, otherRange) {
502  return range.startOffset === otherRange.startOffset &&
503         range.endOffset === otherRange.endOffset;
504}
505
506function doesRangeContainOtherRange(range, otherRange) {
507  return range.startOffset <= otherRange.startOffset &&
508         range.endOffset >= otherRange.endOffset;
509}
510
511module.exports = { setupCoverage };
512