'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 (?\d+ )?\*\//; const kLineEndingRegex = /\r?\n$/u; const kLineSplitRegex = /(?<=\r?\n)/u; const kStatusRegex = /\/\* node:coverage (?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 };