node/lib/internal/test_runner/coverage.js
Moshe Atlow 47c5152545
test_runner: fix typescript coverage
PR-URL: https://github.com/nodejs/node/pull/49406
Fixes: https://github.com/nodejs/node/issues/49398
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
2023-09-04 21:45:07 +00:00

542 lines
16 KiB
JavaScript

'use strict';
const {
ArrayFrom,
ArrayPrototypeMap,
ArrayPrototypePush,
JSONParse,
MathFloor,
NumberParseInt,
RegExpPrototypeExec,
RegExpPrototypeSymbolSplit,
SafeMap,
SafeSet,
StringPrototypeIncludes,
StringPrototypeLocaleCompare,
StringPrototypeStartsWith,
MathMax,
} = 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.count = 0;
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 functionReports = [];
const branchReports = [];
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];
let maxCountPerFunction = 0;
for (let k = 0; k < ranges.length; ++k) {
const range = ranges[k];
maxCountPerFunction = MathMax(maxCountPerFunction, range.count);
mapRangeToLines(range, lines);
if (isBlockCoverage) {
ArrayPrototypePush(branchReports, {
__proto__: null,
line: range.lines[0].line,
count: range.count,
});
if (range.count !== 0 ||
range.ignoredLines === range.lines.length) {
branchesCovered++;
}
totalBranches++;
}
}
if (j > 0 && ranges.length > 0) {
const range = ranges[0];
ArrayPrototypePush(functionReports, {
__proto__: null,
name: functions[j].functionName,
count: maxCountPerFunction,
line: range.lines[0].line,
});
if (range.count !== 0 || range.ignoredLines === range.lines.length) {
functionsCovered++;
}
totalFunctions++;
}
}
let coveredCnt = 0;
const lineReports = [];
for (let j = 0; j < lines.length; ++j) {
const line = lines[j];
if (!line.ignore) {
ArrayPrototypePush(lineReports, {
__proto__: null,
line: line.line,
count: line.count,
});
}
if (line.covered || line.ignore) {
coveredCnt++;
}
}
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),
functions: functionReports,
branches: branchReports,
lines: lineReports,
});
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, { __proto__: null, 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;
line.count = 0;
}
if (count > 0 && startOffset <= line.startOffset &&
endOffset >= line.endOffset) {
line.count = count;
}
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, TestCoverage };