'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 (?\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]; 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 };