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