| '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('internal/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 }; |