1'use strict'; 2const { 3 ArrayFrom, 4 ArrayIsArray, 5 ArrayPrototypeFilter, 6 ArrayPrototypeForEach, 7 ArrayPrototypeIncludes, 8 ArrayPrototypeMap, 9 ArrayPrototypePush, 10 ArrayPrototypeShift, 11 ArrayPrototypeSlice, 12 ArrayPrototypeSome, 13 ArrayPrototypeSort, 14 ObjectAssign, 15 PromisePrototypeThen, 16 SafePromiseAll, 17 SafePromiseAllReturnVoid, 18 SafePromiseAllSettledReturnVoid, 19 PromiseResolve, 20 SafeMap, 21 SafeSet, 22 StringPrototypeIndexOf, 23 StringPrototypeSlice, 24 StringPrototypeStartsWith, 25 TypedArrayPrototypeGetLength, 26 TypedArrayPrototypeSubarray, 27} = primordials; 28 29const { spawn } = require('child_process'); 30const { readdirSync, statSync } = require('fs'); 31const { finished } = require('internal/streams/end-of-stream'); 32const { DefaultDeserializer, DefaultSerializer } = require('v8'); 33// TODO(aduh95): switch to internal/readline/interface when backporting to Node.js 16.x is no longer a concern. 34const { createInterface } = require('readline'); 35const { deserializeError } = require('internal/error_serdes'); 36const { Buffer } = require('buffer'); 37const { FilesWatcher } = require('internal/watch_mode/files_watcher'); 38const console = require('internal/console/global'); 39const { 40 codes: { 41 ERR_INVALID_ARG_TYPE, 42 ERR_INVALID_ARG_VALUE, 43 ERR_TEST_FAILURE, 44 ERR_OUT_OF_RANGE, 45 }, 46} = require('internal/errors'); 47const { 48 validateArray, 49 validateBoolean, 50 validateFunction, 51 validateObject, 52 validateInteger, 53} = require('internal/validators'); 54const { getInspectPort, isUsingInspector, isInspectorMessage } = require('internal/util/inspector'); 55const { isRegExp } = require('internal/util/types'); 56const { kEmptyObject } = require('internal/util'); 57const { kEmitMessage } = require('internal/test_runner/tests_stream'); 58const { createTestTree } = require('internal/test_runner/harness'); 59const { 60 kAborted, 61 kCancelledByParent, 62 kSubtestsFailed, 63 kTestCodeFailure, 64 kTestTimeoutFailure, 65 Test, 66} = require('internal/test_runner/test'); 67 68const { 69 convertStringToRegExp, 70 countCompletedTest, 71 doesPathMatchFilter, 72 isSupportedFileType, 73} = require('internal/test_runner/utils'); 74const { basename, join, resolve } = require('path'); 75const { once } = require('events'); 76const { 77 triggerUncaughtException, 78} = internalBinding('errors'); 79 80const kFilterArgs = ['--test', '--experimental-test-coverage', '--watch']; 81const kFilterArgValues = ['--test-reporter', '--test-reporter-destination']; 82const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled', 'skipped', 'todo', 'duration_ms']; 83 84const kCanceledTests = new SafeSet() 85 .add(kCancelledByParent).add(kAborted).add(kTestTimeoutFailure); 86 87let kResistStopPropagation; 88 89// TODO(cjihrig): Replace this with recursive readdir once it lands. 90function processPath(path, testFiles, options) { 91 const stats = statSync(path); 92 93 if (stats.isFile()) { 94 if (options.userSupplied || 95 (options.underTestDir && isSupportedFileType(path)) || 96 doesPathMatchFilter(path)) { 97 testFiles.add(path); 98 } 99 } else if (stats.isDirectory()) { 100 const name = basename(path); 101 102 if (!options.userSupplied && name === 'node_modules') { 103 return; 104 } 105 106 // 'test' directories get special treatment. Recursively add all .js, 107 // .cjs, and .mjs files in the 'test' directory. 108 const isTestDir = name === 'test'; 109 const { underTestDir } = options; 110 const entries = readdirSync(path); 111 112 if (isTestDir) { 113 options.underTestDir = true; 114 } 115 116 options.userSupplied = false; 117 118 for (let i = 0; i < entries.length; i++) { 119 processPath(join(path, entries[i]), testFiles, options); 120 } 121 122 options.underTestDir = underTestDir; 123 } 124} 125 126function createTestFileList() { 127 const cwd = process.cwd(); 128 const hasUserSuppliedPaths = process.argv.length > 1; 129 const testPaths = hasUserSuppliedPaths ? 130 ArrayPrototypeSlice(process.argv, 1) : [cwd]; 131 const testFiles = new SafeSet(); 132 133 try { 134 for (let i = 0; i < testPaths.length; i++) { 135 const absolutePath = resolve(testPaths[i]); 136 137 processPath(absolutePath, testFiles, { 138 __proto__: null, 139 userSupplied: true, 140 }); 141 } 142 } catch (err) { 143 if (err?.code === 'ENOENT') { 144 console.error(`Could not find '${err.path}'`); 145 process.exit(1); 146 } 147 148 throw err; 149 } 150 151 return ArrayPrototypeSort(ArrayFrom(testFiles)); 152} 153 154function filterExecArgv(arg, i, arr) { 155 return !ArrayPrototypeIncludes(kFilterArgs, arg) && 156 !ArrayPrototypeSome(kFilterArgValues, (p) => arg === p || (i > 0 && arr[i - 1] === p) || StringPrototypeStartsWith(arg, `${p}=`)); 157} 158 159function getRunArgs(path, { inspectPort, testNamePatterns, only }) { 160 const argv = ArrayPrototypeFilter(process.execArgv, filterExecArgv); 161 if (isUsingInspector()) { 162 ArrayPrototypePush(argv, `--inspect-port=${getInspectPort(inspectPort)}`); 163 } 164 if (testNamePatterns != null) { 165 ArrayPrototypeForEach(testNamePatterns, (pattern) => ArrayPrototypePush(argv, `--test-name-pattern=${pattern}`)); 166 } 167 if (only === true) { 168 ArrayPrototypePush(argv, '--test-only'); 169 } 170 ArrayPrototypePush(argv, path); 171 172 return argv; 173} 174 175const serializer = new DefaultSerializer(); 176serializer.writeHeader(); 177const v8Header = serializer.releaseBuffer(); 178const kV8HeaderLength = TypedArrayPrototypeGetLength(v8Header); 179const kSerializedSizeHeader = 4 + kV8HeaderLength; 180 181class FileTest extends Test { 182 // This class maintains two buffers: 183 #reportBuffer = []; // Parsed items waiting for this.isClearToSend() 184 #rawBuffer = []; // Raw data waiting to be parsed 185 #rawBufferSize = 0; 186 #reportedChildren = 0; 187 failedSubtests = false; 188 189 constructor(options) { 190 super(options); 191 this.loc ??= { 192 __proto__: null, 193 line: 1, 194 column: 1, 195 file: resolve(this.name), 196 }; 197 } 198 199 #skipReporting() { 200 return this.#reportedChildren > 0 && (!this.error || this.error.failureType === kSubtestsFailed); 201 } 202 #checkNestedComment(comment) { 203 const firstSpaceIndex = StringPrototypeIndexOf(comment, ' '); 204 if (firstSpaceIndex === -1) return false; 205 const secondSpaceIndex = StringPrototypeIndexOf(comment, ' ', firstSpaceIndex + 1); 206 return secondSpaceIndex === -1 && 207 ArrayPrototypeIncludes(kDiagnosticsFilterArgs, StringPrototypeSlice(comment, 0, firstSpaceIndex)); 208 } 209 #handleReportItem(item) { 210 const isTopLevel = item.data.nesting === 0; 211 if (isTopLevel) { 212 if (item.type === 'test:plan' && this.#skipReporting()) { 213 return; 214 } 215 if (item.type === 'test:diagnostic' && this.#checkNestedComment(item.data.message)) { 216 return; 217 } 218 } 219 if (item.data.details?.error) { 220 item.data.details.error = deserializeError(item.data.details.error); 221 } 222 if (item.type === 'test:pass' || item.type === 'test:fail') { 223 item.data.testNumber = isTopLevel ? (this.root.harness.counters.topLevel + 1) : item.data.testNumber; 224 countCompletedTest({ 225 __proto__: null, 226 name: item.data.name, 227 finished: true, 228 skipped: item.data.skip !== undefined, 229 isTodo: item.data.todo !== undefined, 230 passed: item.type === 'test:pass', 231 cancelled: kCanceledTests.has(item.data.details?.error?.failureType), 232 nesting: item.data.nesting, 233 reportedType: item.data.details?.type, 234 }, this.root.harness); 235 } 236 this.reporter[kEmitMessage](item.type, item.data); 237 } 238 #accumulateReportItem(item) { 239 if (item.type !== 'test:pass' && item.type !== 'test:fail') { 240 return; 241 } 242 this.#reportedChildren++; 243 if (item.data.nesting === 0 && item.type === 'test:fail') { 244 this.failedSubtests = true; 245 } 246 } 247 #drainReportBuffer() { 248 if (this.#reportBuffer.length > 0) { 249 ArrayPrototypeForEach(this.#reportBuffer, (ast) => this.#handleReportItem(ast)); 250 this.#reportBuffer = []; 251 } 252 } 253 addToReport(item) { 254 this.#accumulateReportItem(item); 255 if (!this.isClearToSend()) { 256 ArrayPrototypePush(this.#reportBuffer, item); 257 return; 258 } 259 this.#drainReportBuffer(); 260 this.#handleReportItem(item); 261 } 262 reportStarted() {} 263 drain() { 264 this.#drainRawBuffer(); 265 this.#drainReportBuffer(); 266 } 267 report() { 268 this.drain(); 269 const skipReporting = this.#skipReporting(); 270 if (!skipReporting) { 271 super.reportStarted(); 272 super.report(); 273 } 274 } 275 parseMessage(readData) { 276 let dataLength = TypedArrayPrototypeGetLength(readData); 277 if (dataLength === 0) return; 278 const partialV8Header = readData[dataLength - 1] === v8Header[0]; 279 280 if (partialV8Header) { 281 // This will break if v8Header length (2 bytes) is changed. 282 // However it is covered by tests. 283 readData = TypedArrayPrototypeSubarray(readData, 0, dataLength - 1); 284 dataLength--; 285 } 286 287 if (this.#rawBuffer[0] && TypedArrayPrototypeGetLength(this.#rawBuffer[0]) < kSerializedSizeHeader) { 288 this.#rawBuffer[0] = Buffer.concat([this.#rawBuffer[0], readData]); 289 } else { 290 ArrayPrototypePush(this.#rawBuffer, readData); 291 } 292 this.#rawBufferSize += dataLength; 293 this.#proccessRawBuffer(); 294 295 if (partialV8Header) { 296 ArrayPrototypePush(this.#rawBuffer, TypedArrayPrototypeSubarray(v8Header, 0, 1)); 297 this.#rawBufferSize++; 298 } 299 } 300 #drainRawBuffer() { 301 while (this.#rawBuffer.length > 0) { 302 this.#proccessRawBuffer(); 303 } 304 } 305 #proccessRawBuffer() { 306 // This method is called when it is known that there is at least one message 307 let bufferHead = this.#rawBuffer[0]; 308 let headerIndex = bufferHead.indexOf(v8Header); 309 let nonSerialized = Buffer.alloc(0); 310 311 while (bufferHead && headerIndex !== 0) { 312 const nonSerializedData = headerIndex === -1 ? 313 bufferHead : 314 bufferHead.slice(0, headerIndex); 315 nonSerialized = Buffer.concat([nonSerialized, nonSerializedData]); 316 this.#rawBufferSize -= TypedArrayPrototypeGetLength(nonSerializedData); 317 if (headerIndex === -1) { 318 ArrayPrototypeShift(this.#rawBuffer); 319 } else { 320 this.#rawBuffer[0] = TypedArrayPrototypeSubarray(bufferHead, headerIndex); 321 } 322 bufferHead = this.#rawBuffer[0]; 323 headerIndex = bufferHead?.indexOf(v8Header); 324 } 325 326 if (TypedArrayPrototypeGetLength(nonSerialized) > 0) { 327 this.addToReport({ 328 __proto__: null, 329 type: 'test:stdout', 330 data: { __proto__: null, file: this.name, message: nonSerialized.toString('utf-8') }, 331 }); 332 } 333 334 while (bufferHead?.length >= kSerializedSizeHeader) { 335 // We call `readUInt32BE` manually here, because this is faster than first converting 336 // it to a buffer and using `readUInt32BE` on that. 337 const fullMessageSize = ( 338 bufferHead[kV8HeaderLength] << 24 | 339 bufferHead[kV8HeaderLength + 1] << 16 | 340 bufferHead[kV8HeaderLength + 2] << 8 | 341 bufferHead[kV8HeaderLength + 3] 342 ) + kSerializedSizeHeader; 343 344 if (this.#rawBufferSize < fullMessageSize) break; 345 346 const concatenatedBuffer = this.#rawBuffer.length === 1 ? 347 this.#rawBuffer[0] : Buffer.concat(this.#rawBuffer, this.#rawBufferSize); 348 349 const deserializer = new DefaultDeserializer( 350 TypedArrayPrototypeSubarray(concatenatedBuffer, kSerializedSizeHeader, fullMessageSize), 351 ); 352 353 bufferHead = TypedArrayPrototypeSubarray(concatenatedBuffer, fullMessageSize); 354 this.#rawBufferSize = TypedArrayPrototypeGetLength(bufferHead); 355 this.#rawBuffer = this.#rawBufferSize !== 0 ? [bufferHead] : []; 356 357 deserializer.readHeader(); 358 const item = deserializer.readValue(); 359 this.addToReport(item); 360 } 361 } 362} 363 364function runTestFile(path, filesWatcher, opts) { 365 const watchMode = filesWatcher != null; 366 const subtest = opts.root.createSubtest(FileTest, path, async (t) => { 367 const args = getRunArgs(path, opts); 368 const stdio = ['pipe', 'pipe', 'pipe']; 369 const env = { __proto__: null, ...process.env, NODE_TEST_CONTEXT: 'child-v8' }; 370 if (watchMode) { 371 stdio.push('ipc'); 372 env.WATCH_REPORT_DEPENDENCIES = '1'; 373 } 374 if (opts.root.harness.shouldColorizeTestFiles) { 375 env.FORCE_COLOR = '1'; 376 } 377 378 const child = spawn(process.execPath, args, { __proto__: null, signal: t.signal, encoding: 'utf8', env, stdio }); 379 if (watchMode) { 380 filesWatcher.runningProcesses.set(path, child); 381 filesWatcher.watcher.watchChildProcessModules(child, path); 382 } 383 384 let err; 385 386 387 child.on('error', (error) => { 388 err = error; 389 }); 390 391 child.stdout.on('data', (data) => { 392 subtest.parseMessage(data); 393 }); 394 395 const rl = createInterface({ __proto__: null, input: child.stderr }); 396 rl.on('line', (line) => { 397 if (isInspectorMessage(line)) { 398 process.stderr.write(line + '\n'); 399 return; 400 } 401 402 // stderr cannot be treated as TAP, per the spec. However, we want to 403 // surface stderr lines to improve the DX. Inject each line into the 404 // test output as an unknown token as if it came from the TAP parser. 405 subtest.addToReport({ 406 __proto__: null, 407 type: 'test:stderr', 408 data: { __proto__: null, file: path, message: line + '\n' }, 409 }); 410 }); 411 412 const { 0: { 0: code, 1: signal } } = await SafePromiseAll([ 413 once(child, 'exit', { __proto__: null, signal: t.signal }), 414 finished(child.stdout, { __proto__: null, signal: t.signal }), 415 ]); 416 417 if (watchMode) { 418 filesWatcher.runningProcesses.delete(path); 419 filesWatcher.runningSubtests.delete(path); 420 if (filesWatcher.runningSubtests.size === 0) { 421 opts.root.reporter[kEmitMessage]('test:watch:drained'); 422 } 423 } 424 425 if (code !== 0 || signal !== null) { 426 if (!err) { 427 const failureType = subtest.failedSubtests ? kSubtestsFailed : kTestCodeFailure; 428 err = ObjectAssign(new ERR_TEST_FAILURE('test failed', failureType), { 429 __proto__: null, 430 exitCode: code, 431 signal: signal, 432 // The stack will not be useful since the failures came from tests 433 // in a child process. 434 stack: undefined, 435 }); 436 } 437 438 throw err; 439 } 440 }); 441 return subtest.start(); 442} 443 444function watchFiles(testFiles, opts) { 445 const runningProcesses = new SafeMap(); 446 const runningSubtests = new SafeMap(); 447 const watcher = new FilesWatcher({ __proto__: null, debounce: 200, mode: 'filter', signal: opts.signal }); 448 const filesWatcher = { __proto__: null, watcher, runningProcesses, runningSubtests }; 449 450 watcher.on('changed', ({ owners }) => { 451 watcher.unfilterFilesOwnedBy(owners); 452 PromisePrototypeThen(SafePromiseAllReturnVoid(testFiles, async (file) => { 453 if (!owners.has(file)) { 454 return; 455 } 456 const runningProcess = runningProcesses.get(file); 457 if (runningProcess) { 458 runningProcess.kill(); 459 await once(runningProcess, 'exit'); 460 } 461 if (!runningSubtests.size) { 462 // Reset the topLevel counter 463 opts.root.harness.counters.topLevel = 0; 464 } 465 await runningSubtests.get(file); 466 runningSubtests.set(file, runTestFile(file, filesWatcher, opts)); 467 }, undefined, (error) => { 468 triggerUncaughtException(error, true /* fromPromise */); 469 })); 470 }); 471 if (opts.signal) { 472 kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation; 473 opts.signal.addEventListener( 474 'abort', 475 () => opts.root.postRun(), 476 { __proto__: null, once: true, [kResistStopPropagation]: true }, 477 ); 478 } 479 480 return filesWatcher; 481} 482 483function run(options) { 484 if (options === null || typeof options !== 'object') { 485 options = kEmptyObject; 486 } 487 let { testNamePatterns, shard } = options; 488 const { concurrency, timeout, signal, files, inspectPort, watch, setup, only } = options; 489 490 if (files != null) { 491 validateArray(files, 'options.files'); 492 } 493 if (watch != null) { 494 validateBoolean(watch, 'options.watch'); 495 } 496 if (only != null) { 497 validateBoolean(only, 'options.only'); 498 } 499 if (shard != null) { 500 validateObject(shard, 'options.shard'); 501 // Avoid re-evaluating the shard object in case it's a getter 502 shard = { __proto__: null, index: shard.index, total: shard.total }; 503 504 validateInteger(shard.total, 'options.shard.total', 1); 505 validateInteger(shard.index, 'options.shard.index'); 506 507 if (shard.index <= 0 || shard.total < shard.index) { 508 throw new ERR_OUT_OF_RANGE('options.shard.index', `>= 1 && <= ${shard.total} ("options.shard.total")`, shard.index); 509 } 510 511 if (watch) { 512 throw new ERR_INVALID_ARG_VALUE('options.shard', watch, 'shards not supported with watch mode'); 513 } 514 } 515 if (setup != null) { 516 validateFunction(setup, 'options.setup'); 517 } 518 if (testNamePatterns != null) { 519 if (!ArrayIsArray(testNamePatterns)) { 520 testNamePatterns = [testNamePatterns]; 521 } 522 523 testNamePatterns = ArrayPrototypeMap(testNamePatterns, (value, i) => { 524 if (isRegExp(value)) { 525 return value; 526 } 527 const name = `options.testNamePatterns[${i}]`; 528 if (typeof value === 'string') { 529 return convertStringToRegExp(value, name); 530 } 531 throw new ERR_INVALID_ARG_TYPE(name, ['string', 'RegExp'], value); 532 }); 533 } 534 535 const root = createTestTree({ __proto__: null, concurrency, timeout, signal }); 536 let testFiles = files ?? createTestFileList(); 537 538 if (shard) { 539 testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1); 540 } 541 542 let postRun = () => root.postRun(); 543 let filesWatcher; 544 const opts = { __proto__: null, root, signal, inspectPort, testNamePatterns, only }; 545 if (watch) { 546 filesWatcher = watchFiles(testFiles, opts); 547 postRun = undefined; 548 } 549 const runFiles = () => { 550 root.harness.bootstrapComplete = true; 551 return SafePromiseAllSettledReturnVoid(testFiles, (path) => { 552 const subtest = runTestFile(path, filesWatcher, opts); 553 filesWatcher?.runningSubtests.set(path, subtest); 554 return subtest; 555 }); 556 }; 557 558 PromisePrototypeThen(PromisePrototypeThen(PromiseResolve(setup?.(root)), runFiles), postRun); 559 560 return root.reporter; 561} 562 563module.exports = { 564 FileTest, // Exported for tests only 565 run, 566}; 567