1'use strict'; 2const { 3 ArrayFrom, 4 ArrayPrototypeMap, 5 ArrayPrototypePush, 6 JSONParse, 7 MathFloor, 8 NumberParseInt, 9 RegExpPrototypeExec, 10 RegExpPrototypeSymbolSplit, 11 SafeMap, 12 SafeSet, 13 StringPrototypeIncludes, 14 StringPrototypeLocaleCompare, 15 StringPrototypeStartsWith, 16} = primordials; 17const { 18 copyFileSync, 19 mkdirSync, 20 mkdtempSync, 21 opendirSync, 22 readFileSync, 23} = require('fs'); 24const { setupCoverageHooks } = require('internal/util'); 25const { tmpdir } = require('os'); 26const { join, resolve } = require('path'); 27const { fileURLToPath } = require('url'); 28const kCoverageFileRegex = /^coverage-(\d+)-(\d{13})-(\d+)\.json$/; 29const kIgnoreRegex = /\/\* node:coverage ignore next (?<count>\d+ )?\*\//; 30const kLineEndingRegex = /\r?\n$/u; 31const kLineSplitRegex = /(?<=\r?\n)/u; 32const kStatusRegex = /\/\* node:coverage (?<status>enable|disable) \*\//; 33 34class CoverageLine { 35 #covered; 36 37 constructor(line, src, startOffset) { 38 const newlineLength = 39 RegExpPrototypeExec(kLineEndingRegex, src)?.[0].length ?? 0; 40 41 this.line = line; 42 this.src = src; 43 this.startOffset = startOffset; 44 this.endOffset = startOffset + src.length - newlineLength; 45 this.ignore = false; 46 this.#covered = true; 47 } 48 49 get covered() { 50 return this.#covered; 51 } 52 53 set covered(isCovered) { 54 // V8 can generate multiple ranges that span the same line. 55 if (!this.#covered) { 56 return; 57 } 58 59 this.#covered = isCovered; 60 } 61} 62 63class TestCoverage { 64 constructor(coverageDirectory, originalCoverageDirectory, workingDirectory) { 65 this.coverageDirectory = coverageDirectory; 66 this.originalCoverageDirectory = originalCoverageDirectory; 67 this.workingDirectory = workingDirectory; 68 } 69 70 summary() { 71 internalBinding('profiler').takeCoverage(); 72 const coverage = getCoverageFromDirectory(this.coverageDirectory); 73 const coverageSummary = { 74 __proto__: null, 75 workingDirectory: this.workingDirectory, 76 files: [], 77 totals: { 78 __proto__: null, 79 totalLineCount: 0, 80 totalBranchCount: 0, 81 totalFunctionCount: 0, 82 coveredLineCount: 0, 83 coveredBranchCount: 0, 84 coveredFunctionCount: 0, 85 coveredLinePercent: 0, 86 coveredBranchPercent: 0, 87 coveredFunctionPercent: 0, 88 }, 89 }; 90 91 if (!coverage) { 92 return coverageSummary; 93 } 94 95 for (let i = 0; i < coverage.length; ++i) { 96 const { functions, url } = coverage[i]; 97 98 // Split the file source into lines. Make sure the lines maintain their 99 // original line endings because those characters are necessary for 100 // determining offsets in the file. 101 const filePath = fileURLToPath(url); 102 let source; 103 104 try { 105 source = readFileSync(filePath, 'utf8'); 106 } catch { 107 // The file can no longer be read. It may have been deleted among 108 // other possibilities. Leave it out of the coverage report. 109 continue; 110 } 111 112 const linesWithBreaks = 113 RegExpPrototypeSymbolSplit(kLineSplitRegex, source); 114 let ignoreCount = 0; 115 let enabled = true; 116 let offset = 0; 117 let totalBranches = 0; 118 let totalFunctions = 0; 119 let branchesCovered = 0; 120 let functionsCovered = 0; 121 122 const lines = ArrayPrototypeMap(linesWithBreaks, (line, i) => { 123 const startOffset = offset; 124 const coverageLine = new CoverageLine(i + 1, line, startOffset); 125 126 offset += line.length; 127 128 // Determine if this line is being ignored. 129 if (ignoreCount > 0) { 130 ignoreCount--; 131 coverageLine.ignore = true; 132 } else if (!enabled) { 133 coverageLine.ignore = true; 134 } 135 136 if (!coverageLine.ignore) { 137 // If this line is not already being ignored, check for ignore 138 // comments. 139 const match = RegExpPrototypeExec(kIgnoreRegex, line); 140 141 if (match !== null) { 142 ignoreCount = NumberParseInt(match.groups?.count ?? 1, 10); 143 } 144 } 145 146 // Check for comments to enable/disable coverage no matter what. These 147 // take precedence over ignore comments. 148 const match = RegExpPrototypeExec(kStatusRegex, line); 149 const status = match?.groups?.status; 150 151 if (status) { 152 ignoreCount = 0; 153 enabled = status === 'enable'; 154 } 155 156 return coverageLine; 157 }); 158 159 for (let j = 0; j < functions.length; ++j) { 160 const { isBlockCoverage, ranges } = functions[j]; 161 162 for (let k = 0; k < ranges.length; ++k) { 163 const range = ranges[k]; 164 165 mapRangeToLines(range, lines); 166 167 if (isBlockCoverage) { 168 if (range.count !== 0 || 169 range.ignoredLines === range.lines.length) { 170 branchesCovered++; 171 } 172 173 totalBranches++; 174 } 175 } 176 177 if (j > 0 && ranges.length > 0) { 178 const range = ranges[0]; 179 180 if (range.count !== 0 || range.ignoredLines === range.lines.length) { 181 functionsCovered++; 182 } 183 184 totalFunctions++; 185 } 186 } 187 188 let coveredCnt = 0; 189 const uncoveredLineNums = []; 190 191 for (let j = 0; j < lines.length; ++j) { 192 const line = lines[j]; 193 194 if (line.covered || line.ignore) { 195 coveredCnt++; 196 } else { 197 ArrayPrototypePush(uncoveredLineNums, line.line); 198 } 199 } 200 201 ArrayPrototypePush(coverageSummary.files, { 202 __proto__: null, 203 path: filePath, 204 totalLineCount: lines.length, 205 totalBranchCount: totalBranches, 206 totalFunctionCount: totalFunctions, 207 coveredLineCount: coveredCnt, 208 coveredBranchCount: branchesCovered, 209 coveredFunctionCount: functionsCovered, 210 coveredLinePercent: toPercentage(coveredCnt, lines.length), 211 coveredBranchPercent: toPercentage(branchesCovered, totalBranches), 212 coveredFunctionPercent: toPercentage(functionsCovered, totalFunctions), 213 uncoveredLineNumbers: uncoveredLineNums, 214 }); 215 216 coverageSummary.totals.totalLineCount += lines.length; 217 coverageSummary.totals.totalBranchCount += totalBranches; 218 coverageSummary.totals.totalFunctionCount += totalFunctions; 219 coverageSummary.totals.coveredLineCount += coveredCnt; 220 coverageSummary.totals.coveredBranchCount += branchesCovered; 221 coverageSummary.totals.coveredFunctionCount += functionsCovered; 222 } 223 224 coverageSummary.totals.coveredLinePercent = toPercentage( 225 coverageSummary.totals.coveredLineCount, 226 coverageSummary.totals.totalLineCount, 227 ); 228 coverageSummary.totals.coveredBranchPercent = toPercentage( 229 coverageSummary.totals.coveredBranchCount, 230 coverageSummary.totals.totalBranchCount, 231 ); 232 coverageSummary.totals.coveredFunctionPercent = toPercentage( 233 coverageSummary.totals.coveredFunctionCount, 234 coverageSummary.totals.totalFunctionCount, 235 ); 236 coverageSummary.files.sort(sortCoverageFiles); 237 238 return coverageSummary; 239 } 240 241 cleanup() { 242 // Restore the original value of process.env.NODE_V8_COVERAGE. Then, copy 243 // all of the created coverage files to the original coverage directory. 244 if (this.originalCoverageDirectory === undefined) { 245 delete process.env.NODE_V8_COVERAGE; 246 return; 247 } 248 249 process.env.NODE_V8_COVERAGE = this.originalCoverageDirectory; 250 let dir; 251 252 try { 253 mkdirSync(this.originalCoverageDirectory, { recursive: true }); 254 dir = opendirSync(this.coverageDirectory); 255 256 for (let entry; (entry = dir.readSync()) !== null;) { 257 const src = join(this.coverageDirectory, entry.name); 258 const dst = join(this.originalCoverageDirectory, entry.name); 259 copyFileSync(src, dst); 260 } 261 } finally { 262 if (dir) { 263 dir.closeSync(); 264 } 265 } 266 } 267} 268 269function toPercentage(covered, total) { 270 return total === 0 ? 100 : (covered / total) * 100; 271} 272 273function sortCoverageFiles(a, b) { 274 return StringPrototypeLocaleCompare(a.path, b.path); 275} 276 277function setupCoverage() { 278 let originalCoverageDirectory = process.env.NODE_V8_COVERAGE; 279 const cwd = process.cwd(); 280 281 if (originalCoverageDirectory) { 282 // NODE_V8_COVERAGE was already specified. Convert it to an absolute path 283 // and store it for later. The test runner will use a temporary directory 284 // so that no preexisting coverage files interfere with the results of the 285 // coverage report. Then, once the coverage is computed, move the coverage 286 // files back to the original NODE_V8_COVERAGE directory. 287 originalCoverageDirectory = resolve(cwd, originalCoverageDirectory); 288 } 289 290 const coverageDirectory = mkdtempSync(join(tmpdir(), 'node-coverage-')); 291 const enabled = setupCoverageHooks(coverageDirectory); 292 293 if (!enabled) { 294 return null; 295 } 296 297 // Ensure that NODE_V8_COVERAGE is set so that coverage can propagate to 298 // child processes. 299 process.env.NODE_V8_COVERAGE = coverageDirectory; 300 301 return new TestCoverage(coverageDirectory, originalCoverageDirectory, cwd); 302} 303 304function mapRangeToLines(range, lines) { 305 const { startOffset, endOffset, count } = range; 306 const mappedLines = []; 307 let ignoredLines = 0; 308 let start = 0; 309 let end = lines.length; 310 let mid; 311 312 while (start <= end) { 313 mid = MathFloor((start + end) / 2); 314 let line = lines[mid]; 315 316 if (startOffset >= line.startOffset && startOffset <= line.endOffset) { 317 while (endOffset > line?.startOffset) { 318 // If the range is not covered, and the range covers the entire line, 319 // then mark that line as not covered. 320 if (count === 0 && startOffset <= line.startOffset && 321 endOffset >= line.endOffset) { 322 line.covered = false; 323 } 324 325 ArrayPrototypePush(mappedLines, line); 326 327 if (line.ignore) { 328 ignoredLines++; 329 } 330 331 mid++; 332 line = lines[mid]; 333 } 334 335 break; 336 } else if (startOffset >= line.endOffset) { 337 start = mid + 1; 338 } else { 339 end = mid - 1; 340 } 341 } 342 343 // Add some useful data to the range. The test runner has read these ranges 344 // from a file, so we own the data structures and can do what we want. 345 range.lines = mappedLines; 346 range.ignoredLines = ignoredLines; 347} 348 349function getCoverageFromDirectory(coverageDirectory) { 350 const result = new SafeMap(); 351 let dir; 352 353 try { 354 dir = opendirSync(coverageDirectory); 355 356 for (let entry; (entry = dir.readSync()) !== null;) { 357 if (RegExpPrototypeExec(kCoverageFileRegex, entry.name) === null) { 358 continue; 359 } 360 361 const coverageFile = join(coverageDirectory, entry.name); 362 const coverage = JSONParse(readFileSync(coverageFile, 'utf8')); 363 364 mergeCoverage(result, coverage.result); 365 } 366 367 return ArrayFrom(result.values()); 368 } finally { 369 if (dir) { 370 dir.closeSync(); 371 } 372 } 373} 374 375function mergeCoverage(merged, coverage) { 376 for (let i = 0; i < coverage.length; ++i) { 377 const newScript = coverage[i]; 378 const { url } = newScript; 379 380 // The first part of this check filters out the node_modules/ directory 381 // from the results. This filter is applied first because most real world 382 // applications will be dominated by third party dependencies. The second 383 // part of the check filters out core modules, which start with 'node:' in 384 // coverage reports, as well as any invalid coverages which have been 385 // observed on Windows. 386 if (StringPrototypeIncludes(url, '/node_modules/') || 387 !StringPrototypeStartsWith(url, 'file:')) { 388 continue; 389 } 390 391 const oldScript = merged.get(url); 392 393 if (oldScript === undefined) { 394 merged.set(url, newScript); 395 } else { 396 mergeCoverageScripts(oldScript, newScript); 397 } 398 } 399} 400 401function mergeCoverageScripts(oldScript, newScript) { 402 // Merge the functions from the new coverage into the functions from the 403 // existing (merged) coverage. 404 for (let i = 0; i < newScript.functions.length; ++i) { 405 const newFn = newScript.functions[i]; 406 let found = false; 407 408 for (let j = 0; j < oldScript.functions.length; ++j) { 409 const oldFn = oldScript.functions[j]; 410 411 if (newFn.functionName === oldFn.functionName && 412 newFn.ranges?.[0].startOffset === oldFn.ranges?.[0].startOffset && 413 newFn.ranges?.[0].endOffset === oldFn.ranges?.[0].endOffset) { 414 // These are the same functions. 415 found = true; 416 417 // If newFn is block level coverage, then it will: 418 // - Replace oldFn if oldFn is not block level coverage. 419 // - Merge with oldFn if it is also block level coverage. 420 // If newFn is not block level coverage, then it has no new data. 421 if (newFn.isBlockCoverage) { 422 if (oldFn.isBlockCoverage) { 423 // Merge the oldFn ranges with the newFn ranges. 424 mergeCoverageRanges(oldFn, newFn); 425 } else { 426 // Replace oldFn with newFn. 427 oldFn.isBlockCoverage = true; 428 oldFn.ranges = newFn.ranges; 429 } 430 } 431 432 break; 433 } 434 } 435 436 if (!found) { 437 // This is a new function to track. This is possible because V8 can 438 // generate a different list of functions depending on which code paths 439 // are executed. For example, if a code path dynamically creates a 440 // function, but that code path is not executed then the function does 441 // not show up in the coverage report. Unfortunately, this also means 442 // that the function counts in the coverage summary can never be 443 // guaranteed to be 100% accurate. 444 ArrayPrototypePush(oldScript.functions, newFn); 445 } 446 } 447} 448 449function mergeCoverageRanges(oldFn, newFn) { 450 const mergedRanges = new SafeSet(); 451 452 // Keep all of the existing covered ranges. 453 for (let i = 0; i < oldFn.ranges.length; ++i) { 454 const oldRange = oldFn.ranges[i]; 455 456 if (oldRange.count > 0) { 457 mergedRanges.add(oldRange); 458 } 459 } 460 461 // Merge in the new ranges where appropriate. 462 for (let i = 0; i < newFn.ranges.length; ++i) { 463 const newRange = newFn.ranges[i]; 464 let exactMatch = false; 465 466 for (let j = 0; j < oldFn.ranges.length; ++j) { 467 const oldRange = oldFn.ranges[j]; 468 469 if (doesRangeEqualOtherRange(newRange, oldRange)) { 470 // These are the same ranges, so keep the existing one. 471 oldRange.count += newRange.count; 472 mergedRanges.add(oldRange); 473 exactMatch = true; 474 break; 475 } 476 477 // Look at ranges representing missing coverage and add ranges that 478 // represent the intersection. 479 if (oldRange.count === 0 && newRange.count === 0) { 480 if (doesRangeContainOtherRange(oldRange, newRange)) { 481 // The new range is completely within the old range. Discard the 482 // larger (old) range, and keep the smaller (new) range. 483 mergedRanges.add(newRange); 484 } else if (doesRangeContainOtherRange(newRange, oldRange)) { 485 // The old range is completely within the new range. Discard the 486 // larger (new) range, and keep the smaller (old) range. 487 mergedRanges.add(oldRange); 488 } 489 } 490 } 491 492 // Add new ranges that do not represent missing coverage. 493 if (newRange.count > 0 && !exactMatch) { 494 mergedRanges.add(newRange); 495 } 496 } 497 498 oldFn.ranges = ArrayFrom(mergedRanges); 499} 500 501function doesRangeEqualOtherRange(range, otherRange) { 502 return range.startOffset === otherRange.startOffset && 503 range.endOffset === otherRange.endOffset; 504} 505 506function doesRangeContainOtherRange(range, otherRange) { 507 return range.startOffset <= otherRange.startOffset && 508 range.endOffset >= otherRange.endOffset; 509} 510 511module.exports = { setupCoverage }; 512