node/lib/internal/test_runner/coverage.js
Antoine du Hamel fe514bf960
lib: enforce use of trailing commas for functions
PR-URL: https://github.com/nodejs/node/pull/46629
Reviewed-By: Jacob Smith <jacob@frende.me>
Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Mohammed Keyvanzadeh <mohammadkeyvanzade94@gmail.com>
2023-02-14 18:45:16 +01:00

372 lines
11 KiB
JavaScript

'use strict';
const {
ArrayPrototypeMap,
ArrayPrototypePush,
JSONParse,
MathFloor,
NumberParseInt,
RegExp,
RegExpPrototypeExec,
RegExpPrototypeSymbolSplit,
StringPrototypeIncludes,
StringPrototypeLocaleCompare,
StringPrototypeStartsWith,
} = primordials;
const {
copyFileSync,
mkdirSync,
mkdtempSync,
opendirSync,
readFileSync,
} = require('fs');
const { setupCoverageHooks } = require('internal/util');
const { tmpdir } = require('os');
const { join, resolve } = require('path');
const { fileURLToPath } = require('url');
const kCoveragePattern =
`^coverage\\-${process.pid}\\-(\\d{13})\\-(\\d+)\\.json$`;
const kCoverageFileRegex = new RegExp(kCoveragePattern);
const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//;
const kLineEndingRegex = /\r?\n$/u;
const kLineSplitRegex = /(?<=\r?\n)/u;
const kStatusRegex = /\/\* node:coverage (?<status>enable|disable) \*\//;
class CoverageLine {
#covered;
constructor(line, src, startOffset) {
const newlineLength =
RegExpPrototypeExec(kLineEndingRegex, src)?.[0].length ?? 0;
this.line = line;
this.src = src;
this.startOffset = startOffset;
this.endOffset = startOffset + src.length - newlineLength;
this.ignore = false;
this.#covered = true;
}
get covered() {
return this.#covered;
}
set covered(isCovered) {
// V8 can generate multiple ranges that span the same line.
if (!this.#covered) {
return;
}
this.#covered = isCovered;
}
}
class TestCoverage {
constructor(coverageDirectory, originalCoverageDirectory, workingDirectory) {
this.coverageDirectory = coverageDirectory;
this.originalCoverageDirectory = originalCoverageDirectory;
this.workingDirectory = workingDirectory;
}
summary() {
internalBinding('profiler').takeCoverage();
const coverage = getCoverageFromDirectory(this.coverageDirectory);
const coverageSummary = {
__proto__: null,
workingDirectory: this.workingDirectory,
files: [],
totals: {
__proto__: null,
totalLineCount: 0,
totalBranchCount: 0,
totalFunctionCount: 0,
coveredLineCount: 0,
coveredBranchCount: 0,
coveredFunctionCount: 0,
coveredLinePercent: 0,
coveredBranchPercent: 0,
coveredFunctionPercent: 0,
},
};
if (!coverage) {
return coverageSummary;
}
for (let i = 0; i < coverage.length; ++i) {
const { functions, url } = coverage[i];
if (StringPrototypeStartsWith(url, 'node:') ||
StringPrototypeIncludes(url, '/node_modules/') ||
// On Windows some generated coverages are invalid.
!StringPrototypeStartsWith(url, 'file:')) {
continue;
}
// Split the file source into lines. Make sure the lines maintain their
// original line endings because those characters are necessary for
// determining offsets in the file.
const filePath = fileURLToPath(url);
const source = readFileSync(filePath, 'utf8');
const linesWithBreaks =
RegExpPrototypeSymbolSplit(kLineSplitRegex, source);
let ignoreCount = 0;
let enabled = true;
let offset = 0;
let totalBranches = 0;
let totalFunctions = 0;
let branchesCovered = 0;
let functionsCovered = 0;
const lines = ArrayPrototypeMap(linesWithBreaks, (line, i) => {
const startOffset = offset;
const coverageLine = new CoverageLine(i + 1, line, startOffset);
offset += line.length;
// Determine if this line is being ignored.
if (ignoreCount > 0) {
ignoreCount--;
coverageLine.ignore = true;
} else if (!enabled) {
coverageLine.ignore = true;
}
if (!coverageLine.ignore) {
// If this line is not already being ignored, check for ignore
// comments.
const match = RegExpPrototypeExec(kIgnoreRegex, line);
if (match !== null) {
ignoreCount = NumberParseInt(match.groups?.count ?? 1, 10);
}
}
// Check for comments to enable/disable coverage no matter what. These
// take precedence over ignore comments.
const match = RegExpPrototypeExec(kStatusRegex, line);
const status = match?.groups?.status;
if (status) {
ignoreCount = 0;
enabled = status === 'enable';
}
return coverageLine;
});
for (let j = 0; j < functions.length; ++j) {
const { functionName, isBlockCoverage, ranges } = functions[j];
for (let k = 0; k < ranges.length; ++k) {
const range = ranges[k];
mapRangeToLines(range, lines);
if (isBlockCoverage) {
if (range.count !== 0 ||
range.ignoredLines === range.lines.length) {
branchesCovered++;
}
totalBranches++;
}
}
if (functionName.length > 0 && ranges.length > 0) {
const range = ranges[0];
if (range.count !== 0 || range.ignoredLines === range.lines.length) {
functionsCovered++;
}
totalFunctions++;
}
}
let coveredCnt = 0;
const uncoveredLineNums = [];
for (let j = 0; j < lines.length; ++j) {
const line = lines[j];
if (line.covered || line.ignore) {
coveredCnt++;
} else {
ArrayPrototypePush(uncoveredLineNums, line.line);
}
}
ArrayPrototypePush(coverageSummary.files, {
__proto__: null,
path: filePath,
totalLineCount: lines.length,
totalBranchCount: totalBranches,
totalFunctionCount: totalFunctions,
coveredLineCount: coveredCnt,
coveredBranchCount: branchesCovered,
coveredFunctionCount: functionsCovered,
coveredLinePercent: toPercentage(coveredCnt, lines.length),
coveredBranchPercent: toPercentage(branchesCovered, totalBranches),
coveredFunctionPercent: toPercentage(functionsCovered, totalFunctions),
uncoveredLineNumbers: uncoveredLineNums,
});
coverageSummary.totals.totalLineCount += lines.length;
coverageSummary.totals.totalBranchCount += totalBranches;
coverageSummary.totals.totalFunctionCount += totalFunctions;
coverageSummary.totals.coveredLineCount += coveredCnt;
coverageSummary.totals.coveredBranchCount += branchesCovered;
coverageSummary.totals.coveredFunctionCount += functionsCovered;
}
coverageSummary.totals.coveredLinePercent = toPercentage(
coverageSummary.totals.coveredLineCount,
coverageSummary.totals.totalLineCount,
);
coverageSummary.totals.coveredBranchPercent = toPercentage(
coverageSummary.totals.coveredBranchCount,
coverageSummary.totals.totalBranchCount,
);
coverageSummary.totals.coveredFunctionPercent = toPercentage(
coverageSummary.totals.coveredFunctionCount,
coverageSummary.totals.totalFunctionCount,
);
coverageSummary.files.sort(sortCoverageFiles);
return coverageSummary;
}
cleanup() {
// Restore the original value of process.env.NODE_V8_COVERAGE. Then, copy
// all of the created coverage files to the original coverage directory.
if (this.originalCoverageDirectory === undefined) {
delete process.env.NODE_V8_COVERAGE;
return;
}
process.env.NODE_V8_COVERAGE = this.originalCoverageDirectory;
let dir;
try {
mkdirSync(this.originalCoverageDirectory, { recursive: true });
dir = opendirSync(this.coverageDirectory);
for (let entry; (entry = dir.readSync()) !== null;) {
const src = join(this.coverageDirectory, entry.name);
const dst = join(this.originalCoverageDirectory, entry.name);
copyFileSync(src, dst);
}
} finally {
if (dir) {
dir.closeSync();
}
}
}
}
function toPercentage(covered, total) {
return total === 0 ? 100 : (covered / total) * 100;
}
function sortCoverageFiles(a, b) {
return StringPrototypeLocaleCompare(a.path, b.path);
}
function setupCoverage() {
let originalCoverageDirectory = process.env.NODE_V8_COVERAGE;
const cwd = process.cwd();
if (originalCoverageDirectory) {
// NODE_V8_COVERAGE was already specified. Convert it to an absolute path
// and store it for later. The test runner will use a temporary directory
// so that no preexisting coverage files interfere with the results of the
// coverage report. Then, once the coverage is computed, move the coverage
// files back to the original NODE_V8_COVERAGE directory.
originalCoverageDirectory = resolve(cwd, originalCoverageDirectory);
}
const coverageDirectory = mkdtempSync(join(tmpdir(), 'node-coverage-'));
const enabled = setupCoverageHooks(coverageDirectory);
if (!enabled) {
return null;
}
// Ensure that NODE_V8_COVERAGE is set so that coverage can propagate to
// child processes.
process.env.NODE_V8_COVERAGE = coverageDirectory;
return new TestCoverage(coverageDirectory, originalCoverageDirectory, cwd);
}
function mapRangeToLines(range, lines) {
const { startOffset, endOffset, count } = range;
const mappedLines = [];
let ignoredLines = 0;
let start = 0;
let end = lines.length;
let mid;
while (start <= end) {
mid = MathFloor((start + end) / 2);
let line = lines[mid];
if (startOffset >= line.startOffset && startOffset <= line.endOffset) {
while (endOffset > line?.startOffset) {
// If the range is not covered, and the range covers the entire line,
// then mark that line as not covered.
if (count === 0 && startOffset <= line.startOffset &&
endOffset >= line.endOffset) {
line.covered = false;
}
ArrayPrototypePush(mappedLines, line);
if (line.ignore) {
ignoredLines++;
}
mid++;
line = lines[mid];
}
break;
} else if (startOffset >= line.endOffset) {
start = mid + 1;
} else {
end = mid - 1;
}
}
// Add some useful data to the range. The test runner has read these ranges
// from a file, so we own the data structures and can do what we want.
range.lines = mappedLines;
range.ignoredLines = ignoredLines;
}
function getCoverageFromDirectory(coverageDirectory) {
// TODO(cjihrig): Instead of only reading the coverage file for this process,
// combine all coverage files in the directory into a single data structure.
let dir;
try {
dir = opendirSync(coverageDirectory);
for (let entry; (entry = dir.readSync()) !== null;) {
if (RegExpPrototypeExec(kCoverageFileRegex, entry.name) === null) {
continue;
}
const coverageFile = join(coverageDirectory, entry.name);
const coverage = JSONParse(readFileSync(coverageFile, 'utf8'));
return coverage.result;
}
} finally {
if (dir) {
dir.closeSync();
}
}
}
module.exports = { setupCoverage };