1'use strict'; 2const { 3 ArrayPrototypeJoin, 4 ArrayPrototypeMap, 5 ArrayPrototypePush, 6 ArrayPrototypeReduce, 7 ObjectCreate, 8 ObjectGetOwnPropertyDescriptor, 9 MathFloor, 10 MathMax, 11 MathMin, 12 NumberPrototypeToFixed, 13 SafePromiseAllReturnArrayLike, 14 RegExp, 15 RegExpPrototypeExec, 16 SafeMap, 17 StringPrototypePadStart, 18 StringPrototypePadEnd, 19 StringPrototypeRepeat, 20 StringPrototypeSlice, 21} = primordials; 22 23const { AsyncResource } = require('async_hooks'); 24const { basename, relative } = require('path'); 25const { createWriteStream } = require('fs'); 26const { pathToFileURL } = require('internal/url'); 27const { createDeferredPromise } = require('internal/util'); 28const { getOptionValue } = require('internal/options'); 29const { green, yellow, red, white, shouldColorize } = require('internal/util/colors'); 30 31const { 32 codes: { 33 ERR_INVALID_ARG_VALUE, 34 ERR_TEST_FAILURE, 35 }, 36 kIsNodeError, 37} = require('internal/errors'); 38const { compose } = require('stream'); 39 40const coverageColors = { 41 __proto__: null, 42 high: green, 43 medium: yellow, 44 low: red, 45}; 46 47const kMultipleCallbackInvocations = 'multipleCallbackInvocations'; 48const kRegExpPattern = /^\/(.*)\/([a-z]*)$/; 49const kSupportedFileExtensions = /\.[cm]?js$/; 50const kTestFilePattern = /((^test(-.+)?)|(.+[.\-_]test))\.[cm]?js$/; 51 52function doesPathMatchFilter(p) { 53 return RegExpPrototypeExec(kTestFilePattern, basename(p)) !== null; 54} 55 56function isSupportedFileType(p) { 57 return RegExpPrototypeExec(kSupportedFileExtensions, p) !== null; 58} 59 60function createDeferredCallback() { 61 let calledCount = 0; 62 const { promise, resolve, reject } = createDeferredPromise(); 63 const cb = (err) => { 64 calledCount++; 65 66 // If the callback is called a second time, let the user know, but 67 // don't let them know more than once. 68 if (calledCount > 1) { 69 if (calledCount === 2) { 70 throw new ERR_TEST_FAILURE( 71 'callback invoked multiple times', 72 kMultipleCallbackInvocations, 73 ); 74 } 75 76 return; 77 } 78 79 if (err) { 80 return reject(err); 81 } 82 83 resolve(); 84 }; 85 86 return { __proto__: null, promise, cb }; 87} 88 89function isTestFailureError(err) { 90 return err?.code === 'ERR_TEST_FAILURE' && kIsNodeError in err; 91} 92 93function convertStringToRegExp(str, name) { 94 const match = RegExpPrototypeExec(kRegExpPattern, str); 95 const pattern = match?.[1] ?? str; 96 const flags = match?.[2] || ''; 97 98 try { 99 return new RegExp(pattern, flags); 100 } catch (err) { 101 const msg = err?.message; 102 103 throw new ERR_INVALID_ARG_VALUE( 104 name, 105 str, 106 `is an invalid regular expression.${msg ? ` ${msg}` : ''}`, 107 ); 108 } 109} 110 111const kBuiltinDestinations = new SafeMap([ 112 ['stdout', process.stdout], 113 ['stderr', process.stderr], 114]); 115 116const kBuiltinReporters = new SafeMap([ 117 ['spec', 'internal/test_runner/reporter/spec'], 118 ['dot', 'internal/test_runner/reporter/dot'], 119 ['tap', 'internal/test_runner/reporter/tap'], 120 ['junit', 'internal/test_runner/reporter/junit'], 121]); 122 123const kDefaultReporter = process.stdout.isTTY ? 'spec' : 'tap'; 124const kDefaultDestination = 'stdout'; 125 126function tryBuiltinReporter(name) { 127 const builtinPath = kBuiltinReporters.get(name); 128 129 if (builtinPath === undefined) { 130 return; 131 } 132 133 return require(builtinPath); 134} 135 136async function getReportersMap(reporters, destinations, rootTest) { 137 return SafePromiseAllReturnArrayLike(reporters, async (name, i) => { 138 const destination = kBuiltinDestinations.get(destinations[i]) ?? createWriteStream(destinations[i]); 139 rootTest.harness.shouldColorizeTestFiles ||= shouldColorize(destination); 140 141 // Load the test reporter passed to --test-reporter 142 let reporter = tryBuiltinReporter(name); 143 144 if (reporter === undefined) { 145 let parentURL; 146 147 try { 148 parentURL = pathToFileURL(process.cwd() + '/').href; 149 } catch { 150 parentURL = 'file:///'; 151 } 152 153 const { esmLoader } = require('internal/process/esm_loader'); 154 reporter = await esmLoader.import(name, parentURL, ObjectCreate(null)); 155 } 156 157 if (reporter?.default) { 158 reporter = reporter.default; 159 } 160 161 if (reporter?.prototype && ObjectGetOwnPropertyDescriptor(reporter.prototype, 'constructor')) { 162 reporter = new reporter(); 163 } 164 165 if (!reporter) { 166 throw new ERR_INVALID_ARG_VALUE('Reporter', name, 'is not a valid reporter'); 167 } 168 169 return { __proto__: null, reporter, destination }; 170 }); 171} 172 173const reporterScope = new AsyncResource('TestReporterScope'); 174const setupTestReporters = reporterScope.bind(async (rootTest) => { 175 const { reporters, destinations } = parseCommandLine(); 176 const reportersMap = await getReportersMap(reporters, destinations, rootTest); 177 for (let i = 0; i < reportersMap.length; i++) { 178 const { reporter, destination } = reportersMap[i]; 179 compose(rootTest.reporter, reporter).pipe(destination); 180 } 181}); 182 183let globalTestOptions; 184 185function parseCommandLine() { 186 if (globalTestOptions) { 187 return globalTestOptions; 188 } 189 190 const isTestRunner = getOptionValue('--test'); 191 const coverage = getOptionValue('--experimental-test-coverage'); 192 const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child'; 193 const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8'; 194 let destinations; 195 let reporters; 196 let testNamePatterns; 197 let testOnlyFlag; 198 199 if (isChildProcessV8) { 200 kBuiltinReporters.set('v8-serializer', 'internal/test_runner/reporter/v8-serializer'); 201 reporters = ['v8-serializer']; 202 destinations = [kDefaultDestination]; 203 } else if (isChildProcess) { 204 reporters = ['tap']; 205 destinations = [kDefaultDestination]; 206 } else { 207 destinations = getOptionValue('--test-reporter-destination'); 208 reporters = getOptionValue('--test-reporter'); 209 if (reporters.length === 0 && destinations.length === 0) { 210 ArrayPrototypePush(reporters, kDefaultReporter); 211 } 212 213 if (reporters.length === 1 && destinations.length === 0) { 214 ArrayPrototypePush(destinations, kDefaultDestination); 215 } 216 217 if (destinations.length !== reporters.length) { 218 throw new ERR_INVALID_ARG_VALUE( 219 '--test-reporter', 220 reporters, 221 'must match the number of specified \'--test-reporter-destination\'', 222 ); 223 } 224 } 225 226 if (isTestRunner) { 227 testOnlyFlag = false; 228 testNamePatterns = null; 229 } else { 230 const testNamePatternFlag = getOptionValue('--test-name-pattern'); 231 testOnlyFlag = getOptionValue('--test-only'); 232 testNamePatterns = testNamePatternFlag?.length > 0 ? 233 ArrayPrototypeMap( 234 testNamePatternFlag, 235 (re) => convertStringToRegExp(re, '--test-name-pattern'), 236 ) : null; 237 } 238 239 globalTestOptions = { 240 __proto__: null, 241 isTestRunner, 242 coverage, 243 testOnlyFlag, 244 testNamePatterns, 245 reporters, 246 destinations, 247 }; 248 249 return globalTestOptions; 250} 251 252function countCompletedTest(test, harness = test.root.harness) { 253 if (test.nesting === 0) { 254 harness.counters.topLevel++; 255 } 256 if (test.reportedType === 'suite') { 257 harness.counters.suites++; 258 return; 259 } 260 // Check SKIP and TODO tests first, as those should not be counted as 261 // failures. 262 if (test.skipped) { 263 harness.counters.skipped++; 264 } else if (test.isTodo) { 265 harness.counters.todo++; 266 } else if (test.cancelled) { 267 harness.counters.cancelled++; 268 } else if (!test.passed) { 269 harness.counters.failed++; 270 } else { 271 harness.counters.passed++; 272 } 273 harness.counters.all++; 274} 275 276 277const memo = new SafeMap(); 278function addTableLine(prefix, width) { 279 const key = `${prefix}-${width}`; 280 let value = memo.get(key); 281 if (value === undefined) { 282 value = `${prefix}${StringPrototypeRepeat('-', width)}\n`; 283 memo.set(key, value); 284 } 285 286 return value; 287} 288 289const kHorizontalEllipsis = '\u2026'; 290function truncateStart(string, width) { 291 return string.length > width ? `${kHorizontalEllipsis}${StringPrototypeSlice(string, string.length - width + 1)}` : string; 292} 293 294function truncateEnd(string, width) { 295 return string.length > width ? `${StringPrototypeSlice(string, 0, width - 1)}${kHorizontalEllipsis}` : string; 296} 297 298function formatLinesToRanges(values) { 299 return ArrayPrototypeMap(ArrayPrototypeReduce(values, (prev, current, index, array) => { 300 if ((index > 0) && ((current - array[index - 1]) === 1)) { 301 prev[prev.length - 1][1] = current; 302 } else { 303 prev.push([current]); 304 } 305 return prev; 306 }, []), (range) => ArrayPrototypeJoin(range, '-')); 307} 308 309function formatUncoveredLines(lines, table) { 310 if (table) return ArrayPrototypeJoin(formatLinesToRanges(lines), ' '); 311 return ArrayPrototypeJoin(lines, ', '); 312} 313 314const kColumns = ['line %', 'branch %', 'funcs %']; 315const kColumnsKeys = ['coveredLinePercent', 'coveredBranchPercent', 'coveredFunctionPercent']; 316const kSeparator = ' | '; 317 318function getCoverageReport(pad, summary, symbol, color, table) { 319 const prefix = `${pad}${symbol}`; 320 let report = `${color}${prefix}start of coverage report\n`; 321 322 let filePadLength; 323 let columnPadLengths = []; 324 let uncoveredLinesPadLength; 325 let tableWidth; 326 327 if (table) { 328 // Get expected column sizes 329 filePadLength = table && ArrayPrototypeReduce(summary.files, (acc, file) => 330 MathMax(acc, relative(summary.workingDirectory, file.path).length), 0); 331 filePadLength = MathMax(filePadLength, 'file'.length); 332 const fileWidth = filePadLength + 2; 333 334 columnPadLengths = ArrayPrototypeMap(kColumns, (column) => (table ? MathMax(column.length, 6) : 0)); 335 const columnsWidth = ArrayPrototypeReduce(columnPadLengths, (acc, columnPadLength) => acc + columnPadLength + 3, 0); 336 337 uncoveredLinesPadLength = table && ArrayPrototypeReduce(summary.files, (acc, file) => 338 MathMax(acc, formatUncoveredLines(file.uncoveredLineNumbers, table).length), 0); 339 uncoveredLinesPadLength = MathMax(uncoveredLinesPadLength, 'uncovered lines'.length); 340 const uncoveredLinesWidth = uncoveredLinesPadLength + 2; 341 342 tableWidth = fileWidth + columnsWidth + uncoveredLinesWidth; 343 344 // Fit with sensible defaults 345 const availableWidth = (process.stdout.columns || Infinity) - prefix.length; 346 const columnsExtras = tableWidth - availableWidth; 347 if (table && columnsExtras > 0) { 348 // Ensure file name is sufficiently visible 349 const minFilePad = MathMin(8, filePadLength); 350 filePadLength -= MathFloor(columnsExtras * 0.2); 351 filePadLength = MathMax(filePadLength, minFilePad); 352 353 // Get rest of available space, subtracting margins 354 uncoveredLinesPadLength = MathMax(availableWidth - columnsWidth - (filePadLength + 2) - 2, 1); 355 356 // Update table width 357 tableWidth = availableWidth; 358 } else { 359 uncoveredLinesPadLength = Infinity; 360 } 361 } 362 363 364 function getCell(string, width, pad, truncate, coverage) { 365 if (!table) return string; 366 367 let result = string; 368 if (pad) result = pad(result, width); 369 if (truncate) result = truncate(result, width); 370 if (color && coverage !== undefined) { 371 if (coverage > 90) return `${coverageColors.high}${result}${color}`; 372 if (coverage > 50) return `${coverageColors.medium}${result}${color}`; 373 return `${coverageColors.low}${result}${color}`; 374 } 375 return result; 376 } 377 378 // Head 379 if (table) report += addTableLine(prefix, tableWidth); 380 report += `${prefix}${getCell('file', filePadLength, StringPrototypePadEnd, truncateEnd)}${kSeparator}` + 381 `${ArrayPrototypeJoin(ArrayPrototypeMap(kColumns, (column, i) => getCell(column, columnPadLengths[i], StringPrototypePadStart)), kSeparator)}${kSeparator}` + 382 `${getCell('uncovered lines', uncoveredLinesPadLength, false, truncateEnd)}\n`; 383 if (table) report += addTableLine(prefix, tableWidth); 384 385 // Body 386 for (let i = 0; i < summary.files.length; ++i) { 387 const file = summary.files[i]; 388 const relativePath = relative(summary.workingDirectory, file.path); 389 390 let fileCoverage = 0; 391 const coverages = ArrayPrototypeMap(kColumnsKeys, (columnKey) => { 392 const percent = file[columnKey]; 393 fileCoverage += percent; 394 return percent; 395 }); 396 fileCoverage /= kColumnsKeys.length; 397 398 report += `${prefix}${getCell(relativePath, filePadLength, StringPrototypePadEnd, truncateStart, fileCoverage)}${kSeparator}` + 399 `${ArrayPrototypeJoin(ArrayPrototypeMap(coverages, (coverage, j) => getCell(NumberPrototypeToFixed(coverage, 2), columnPadLengths[j], StringPrototypePadStart, false, coverage)), kSeparator)}${kSeparator}` + 400 `${getCell(formatUncoveredLines(file.uncoveredLineNumbers, table), uncoveredLinesPadLength, false, truncateEnd)}\n`; 401 } 402 403 // Foot 404 if (table) report += addTableLine(prefix, tableWidth); 405 report += `${prefix}${getCell('all files', filePadLength, StringPrototypePadEnd, truncateEnd)}${kSeparator}` + 406 `${ArrayPrototypeJoin(ArrayPrototypeMap(kColumnsKeys, (columnKey, j) => getCell(NumberPrototypeToFixed(summary.totals[columnKey], 2), columnPadLengths[j], StringPrototypePadStart, false, summary.totals[columnKey])), kSeparator)} |\n`; 407 if (table) report += addTableLine(prefix, tableWidth); 408 409 report += `${prefix}end of coverage report\n`; 410 if (color) { 411 report += white; 412 } 413 return report; 414} 415 416module.exports = { 417 convertStringToRegExp, 418 countCompletedTest, 419 createDeferredCallback, 420 doesPathMatchFilter, 421 isSupportedFileType, 422 isTestFailureError, 423 parseCommandLine, 424 reporterScope, 425 setupTestReporters, 426 getCoverageReport, 427}; 428