mirror of
https://github.com/nodejs/node.git
synced 2025-04-29 22:40:57 +00:00

The code coverage reporting logic already filters out URLs that don't start with 'file:', so there is no need to also filter out URLs that start with 'node:'. PR-URL: https://github.com/nodejs/node/pull/48070 Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: Moshe Atlow <moshe@atlow.co.il> Reviewed-By: Debadree Chatterjee <debadree333@gmail.com>
512 lines
15 KiB
JavaScript
512 lines
15 KiB
JavaScript
'use strict';
|
|
const {
|
|
ArrayFrom,
|
|
ArrayPrototypeMap,
|
|
ArrayPrototypePush,
|
|
JSONParse,
|
|
MathFloor,
|
|
NumberParseInt,
|
|
RegExpPrototypeExec,
|
|
RegExpPrototypeSymbolSplit,
|
|
SafeMap,
|
|
SafeSet,
|
|
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 kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/;
|
|
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];
|
|
|
|
// 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);
|
|
let source;
|
|
|
|
try {
|
|
source = readFileSync(filePath, 'utf8');
|
|
} catch {
|
|
// The file can no longer be read. It may have been deleted among
|
|
// other possibilities. Leave it out of the coverage report.
|
|
continue;
|
|
}
|
|
|
|
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 { 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 (j > 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) {
|
|
const result = new SafeMap();
|
|
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'));
|
|
|
|
mergeCoverage(result, coverage.result);
|
|
}
|
|
|
|
return ArrayFrom(result.values());
|
|
} finally {
|
|
if (dir) {
|
|
dir.closeSync();
|
|
}
|
|
}
|
|
}
|
|
|
|
function mergeCoverage(merged, coverage) {
|
|
for (let i = 0; i < coverage.length; ++i) {
|
|
const newScript = coverage[i];
|
|
const { url } = newScript;
|
|
|
|
// The first part of this check filters out the node_modules/ directory
|
|
// from the results. This filter is applied first because most real world
|
|
// applications will be dominated by third party dependencies. The second
|
|
// part of the check filters out core modules, which start with 'node:' in
|
|
// coverage reports, as well as any invalid coverages which have been
|
|
// observed on Windows.
|
|
if (StringPrototypeIncludes(url, '/node_modules/') ||
|
|
!StringPrototypeStartsWith(url, 'file:')) {
|
|
continue;
|
|
}
|
|
|
|
const oldScript = merged.get(url);
|
|
|
|
if (oldScript === undefined) {
|
|
merged.set(url, newScript);
|
|
} else {
|
|
mergeCoverageScripts(oldScript, newScript);
|
|
}
|
|
}
|
|
}
|
|
|
|
function mergeCoverageScripts(oldScript, newScript) {
|
|
// Merge the functions from the new coverage into the functions from the
|
|
// existing (merged) coverage.
|
|
for (let i = 0; i < newScript.functions.length; ++i) {
|
|
const newFn = newScript.functions[i];
|
|
let found = false;
|
|
|
|
for (let j = 0; j < oldScript.functions.length; ++j) {
|
|
const oldFn = oldScript.functions[j];
|
|
|
|
if (newFn.functionName === oldFn.functionName &&
|
|
newFn.ranges?.[0].startOffset === oldFn.ranges?.[0].startOffset &&
|
|
newFn.ranges?.[0].endOffset === oldFn.ranges?.[0].endOffset) {
|
|
// These are the same functions.
|
|
found = true;
|
|
|
|
// If newFn is block level coverage, then it will:
|
|
// - Replace oldFn if oldFn is not block level coverage.
|
|
// - Merge with oldFn if it is also block level coverage.
|
|
// If newFn is not block level coverage, then it has no new data.
|
|
if (newFn.isBlockCoverage) {
|
|
if (oldFn.isBlockCoverage) {
|
|
// Merge the oldFn ranges with the newFn ranges.
|
|
mergeCoverageRanges(oldFn, newFn);
|
|
} else {
|
|
// Replace oldFn with newFn.
|
|
oldFn.isBlockCoverage = true;
|
|
oldFn.ranges = newFn.ranges;
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found) {
|
|
// This is a new function to track. This is possible because V8 can
|
|
// generate a different list of functions depending on which code paths
|
|
// are executed. For example, if a code path dynamically creates a
|
|
// function, but that code path is not executed then the function does
|
|
// not show up in the coverage report. Unfortunately, this also means
|
|
// that the function counts in the coverage summary can never be
|
|
// guaranteed to be 100% accurate.
|
|
ArrayPrototypePush(oldScript.functions, newFn);
|
|
}
|
|
}
|
|
}
|
|
|
|
function mergeCoverageRanges(oldFn, newFn) {
|
|
const mergedRanges = new SafeSet();
|
|
|
|
// Keep all of the existing covered ranges.
|
|
for (let i = 0; i < oldFn.ranges.length; ++i) {
|
|
const oldRange = oldFn.ranges[i];
|
|
|
|
if (oldRange.count > 0) {
|
|
mergedRanges.add(oldRange);
|
|
}
|
|
}
|
|
|
|
// Merge in the new ranges where appropriate.
|
|
for (let i = 0; i < newFn.ranges.length; ++i) {
|
|
const newRange = newFn.ranges[i];
|
|
let exactMatch = false;
|
|
|
|
for (let j = 0; j < oldFn.ranges.length; ++j) {
|
|
const oldRange = oldFn.ranges[j];
|
|
|
|
if (doesRangeEqualOtherRange(newRange, oldRange)) {
|
|
// These are the same ranges, so keep the existing one.
|
|
oldRange.count += newRange.count;
|
|
mergedRanges.add(oldRange);
|
|
exactMatch = true;
|
|
break;
|
|
}
|
|
|
|
// Look at ranges representing missing coverage and add ranges that
|
|
// represent the intersection.
|
|
if (oldRange.count === 0 && newRange.count === 0) {
|
|
if (doesRangeContainOtherRange(oldRange, newRange)) {
|
|
// The new range is completely within the old range. Discard the
|
|
// larger (old) range, and keep the smaller (new) range.
|
|
mergedRanges.add(newRange);
|
|
} else if (doesRangeContainOtherRange(newRange, oldRange)) {
|
|
// The old range is completely within the new range. Discard the
|
|
// larger (new) range, and keep the smaller (old) range.
|
|
mergedRanges.add(oldRange);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add new ranges that do not represent missing coverage.
|
|
if (newRange.count > 0 && !exactMatch) {
|
|
mergedRanges.add(newRange);
|
|
}
|
|
}
|
|
|
|
oldFn.ranges = ArrayFrom(mergedRanges);
|
|
}
|
|
|
|
function doesRangeEqualOtherRange(range, otherRange) {
|
|
return range.startOffset === otherRange.startOffset &&
|
|
range.endOffset === otherRange.endOffset;
|
|
}
|
|
|
|
function doesRangeContainOtherRange(range, otherRange) {
|
|
return range.startOffset <= otherRange.startOffset &&
|
|
range.endOffset >= otherRange.endOffset;
|
|
}
|
|
|
|
module.exports = { setupCoverage };
|