1namespace Harness.Parallel.Host { 2 export function start() { 3 const Mocha = require("mocha") as typeof import("mocha"); 4 const Base = Mocha.reporters.Base; 5 const color = Base.color; 6 const cursor = Base.cursor; 7 const ms = require("ms") as typeof import("ms"); 8 const readline = require("readline") as typeof import("readline"); 9 const os = require("os") as typeof import("os"); 10 const tty = require("tty") as typeof import("tty"); 11 const isatty = tty.isatty(1) && tty.isatty(2); 12 const path = require("path") as typeof import("path"); 13 const { fork } = require("child_process") as typeof import("child_process"); 14 const { statSync } = require("fs") as typeof import("fs"); 15 16 // NOTE: paths for module and types for FailedTestReporter _do not_ line up due to our use of --outFile for run.js 17 const FailedTestReporter = require(path.resolve(__dirname, "../../scripts/failed-tests")) as typeof import("../../../scripts/failed-tests"); 18 19 const perfdataFileNameFragment = ".parallelperf"; 20 const perfData = readSavedPerfData(configOption); 21 const newTasks: Task[] = []; 22 let tasks: Task[] = []; 23 let unknownValue: string | undefined; 24 let totalCost = 0; 25 26 class RemoteSuite extends Mocha.Suite { 27 suiteMap = new ts.Map<string, RemoteSuite>(); 28 constructor(title: string) { 29 super(title); 30 this.pending = false; 31 this.delayed = false; 32 } 33 addSuite(suite: RemoteSuite) { 34 super.addSuite(suite); 35 this.suiteMap.set(suite.title, suite); 36 return this; 37 } 38 addTest(test: RemoteTest) { 39 return super.addTest(test); 40 } 41 } 42 43 class RemoteTest extends Mocha.Test { 44 info: ErrorInfo | TestInfo; 45 constructor(info: ErrorInfo | TestInfo) { 46 super(info.name[info.name.length - 1]); 47 this.info = info; 48 this.state = "error" in info ? "failed" : "passed"; // eslint-disable-line no-in-operator 49 this.pending = false; 50 } 51 } 52 53 interface Worker { 54 process: import("child_process").ChildProcess; 55 accumulatedOutput: string; 56 currentTasks?: { file: string }[]; 57 timer?: any; 58 } 59 60 interface ProgressBarsOptions { 61 open: string; 62 close: string; 63 complete: string; 64 incomplete: string; 65 width: number; 66 noColors: boolean; 67 } 68 69 interface ProgressBar { 70 lastN?: number; 71 title?: string; 72 progressColor?: string; 73 text?: string; 74 } 75 76 class ProgressBars { 77 public readonly _options: Readonly<ProgressBarsOptions>; 78 private _enabled: boolean; 79 private _lineCount: number; 80 private _progressBars: ProgressBar[]; 81 constructor(options?: Partial<ProgressBarsOptions>) { 82 if (!options) options = {}; 83 const open = options.open || "["; 84 const close = options.close || "]"; 85 const complete = options.complete || "▬"; 86 const incomplete = options.incomplete || Base.symbols.dot; 87 const maxWidth = Base.window.width - open.length - close.length - 34; 88 const width = minMax(options.width || maxWidth, 10, maxWidth); 89 this._options = { 90 open, 91 complete, 92 incomplete, 93 close, 94 width, 95 noColors: options.noColors || false 96 }; 97 98 this._progressBars = []; 99 this._lineCount = 0; 100 this._enabled = false; 101 } 102 enable() { 103 if (!this._enabled) { 104 process.stdout.write(os.EOL); 105 this._enabled = true; 106 } 107 } 108 disable() { 109 if (this._enabled) { 110 process.stdout.write(os.EOL); 111 this._enabled = false; 112 } 113 } 114 update(index: number, percentComplete: number, color: string, title: string | undefined, titleColor?: string) { 115 percentComplete = minMax(percentComplete, 0, 1); 116 117 const progressBar = this._progressBars[index] || (this._progressBars[index] = {}); 118 const width = this._options.width; 119 const n = Math.floor(width * percentComplete); 120 const i = width - n; 121 if (n === progressBar.lastN && title === progressBar.title && color === progressBar.progressColor) { 122 return; 123 } 124 125 progressBar.lastN = n; 126 progressBar.title = title; 127 progressBar.progressColor = color; 128 129 let progress = " "; 130 progress += this._color("progress", this._options.open); 131 progress += this._color(color, fill(this._options.complete, n)); 132 progress += this._color("progress", fill(this._options.incomplete, i)); 133 progress += this._color("progress", this._options.close); 134 135 if (title) { 136 progress += this._color(titleColor || "progress", " " + title); 137 } 138 139 if (progressBar.text !== progress) { 140 progressBar.text = progress; 141 this._render(index); 142 } 143 } 144 private _render(index: number) { 145 if (!this._enabled || !isatty) { 146 return; 147 } 148 149 cursor.hide(); 150 readline.moveCursor(process.stdout, -process.stdout.columns, -this._lineCount); 151 let lineCount = 0; 152 const numProgressBars = this._progressBars.length; 153 for (let i = 0; i < numProgressBars; i++) { 154 if (i === index) { 155 readline.clearLine(process.stdout, 1); 156 process.stdout.write(this._progressBars[i].text + os.EOL); 157 } 158 else { 159 readline.moveCursor(process.stdout, -process.stdout.columns, +1); 160 } 161 162 lineCount++; 163 } 164 165 this._lineCount = lineCount; 166 cursor.show(); 167 } 168 private _color(type: string, text: string) { 169 return type && !this._options.noColors ? color(type, text) : text; 170 } 171 } 172 173 function perfdataFileName(target?: string) { 174 return `${perfdataFileNameFragment}${target ? `.${target}` : ""}.json`; 175 } 176 177 function readSavedPerfData(target?: string): { [testHash: string]: number } | undefined { 178 const perfDataContents = IO.readFile(perfdataFileName(target)); 179 if (perfDataContents) { 180 return JSON.parse(perfDataContents); 181 } 182 return undefined; 183 } 184 185 function hashName(runner: TestRunnerKind | "unittest", test: string) { 186 return `tsrunner-${runner}://${test}`; 187 } 188 189 function startDelayed(perfData: { [testHash: string]: number } | undefined, totalCost: number) { 190 console.log(`Discovered ${tasks.length} unittest suites` + (newTasks.length ? ` and ${newTasks.length} new suites.` : ".")); 191 console.log("Discovering runner-based tests..."); 192 const discoverStart = +(new Date()); 193 for (const runner of runners) { 194 for (const test of runner.getTestFiles()) { 195 const file = typeof test === "string" ? test : test.file; 196 let size: number; 197 if (!perfData) { 198 try { 199 size = statSync(path.join(runner.workingDirectory, file)).size; 200 } 201 catch { 202 // May be a directory 203 try { 204 size = IO.listFiles(path.join(runner.workingDirectory, file), /.*/g, { recursive: true }).reduce((acc, elem) => acc + statSync(elem).size, 0); 205 } 206 catch { 207 // Unknown test kind, just return 0 and let the historical analysis take over after one run 208 size = 0; 209 } 210 } 211 } 212 else { 213 const hashedName = hashName(runner.kind(), file); 214 size = perfData[hashedName]; 215 if (size === undefined) { 216 size = 0; 217 unknownValue = hashedName; 218 newTasks.push({ runner: runner.kind(), file, size }); 219 continue; 220 } 221 } 222 tasks.push({ runner: runner.kind(), file, size }); 223 totalCost += size; 224 } 225 } 226 tasks.sort((a, b) => a.size - b.size); 227 tasks = tasks.concat(newTasks); 228 const batchCount = workerCount; 229 const packfraction = 0.9; 230 const chunkSize = 1000; // ~1KB or 1s for sending batches near the end of a test 231 const batchSize = (totalCost / workerCount) * packfraction; // Keep spare tests for unittest thread in reserve 232 console.log(`Discovered ${tasks.length} test files in ${+(new Date()) - discoverStart}ms.`); 233 console.log(`Starting to run tests using ${workerCount} threads...`); 234 235 const totalFiles = tasks.length; 236 let passingFiles = 0; 237 let failingFiles = 0; 238 let errorResults: ErrorInfo[] = []; 239 let passingResults: { name: string[] }[] = []; 240 let totalPassing = 0; 241 const startDate = new Date(); 242 243 const progressBars = new ProgressBars({ noColors: Harness.noColors }); // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier 244 const progressUpdateInterval = 1 / progressBars._options.width; 245 let nextProgress = progressUpdateInterval; 246 247 const newPerfData: { [testHash: string]: number } = {}; 248 249 const workers: Worker[] = []; 250 let closedWorkers = 0; 251 for (let i = 0; i < workerCount; i++) { 252 // TODO: Just send the config over the IPC channel or in the command line arguments 253 const config: TestConfig = { light: lightMode, listenForWork: true, runUnitTests: Harness.runUnitTests, stackTraceLimit: Harness.stackTraceLimit, timeout: globalTimeout }; // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier 254 const configPath = ts.combinePaths(taskConfigsFolder, `task-config${i}.json`); 255 IO.writeFile(configPath, JSON.stringify(config)); 256 const worker: Worker = { 257 process: fork(__filename, [`--config="${configPath}"`], { stdio: ["pipe", "pipe", "pipe", "ipc"] }), 258 accumulatedOutput: "", 259 currentTasks: undefined, 260 timer: undefined 261 }; 262 const appendOutput = (d: Buffer) => { 263 worker.accumulatedOutput += d.toString(); 264 console.log(`[Worker ${i}]`, d.toString()); 265 }; 266 worker.process.stderr!.on("data", appendOutput); 267 worker.process.stdout!.on("data", appendOutput); 268 const killChild = (timeout: TaskTimeout) => { 269 worker.process.kill(); 270 console.error(`Worker exceeded ${timeout.duration}ms timeout ${worker.currentTasks && worker.currentTasks.length ? `while running test '${worker.currentTasks[0].file}'.` : `during test setup.`}`); 271 return process.exit(2); 272 }; 273 worker.process.on("error", err => { 274 console.error("Unexpected error in child process:"); 275 console.error(err); 276 return process.exit(2); 277 }); 278 worker.process.on("exit", (code, _signal) => { 279 if (code !== 0) { 280 console.error(`Test worker process exited with nonzero exit code! Output: 281 ${worker.accumulatedOutput}`); 282 return process.exit(2); 283 } 284 }); 285 worker.process.on("message", (data: ParallelClientMessage) => { 286 switch (data.type) { 287 case "error": { 288 console.error(`Test worker encounted unexpected error${data.payload.name ? ` during the execution of test ${data.payload.name}` : ""} and was forced to close: 289 Message: ${data.payload.error} 290 Stack: ${data.payload.stack}`); 291 return process.exit(2); 292 } 293 case "timeout": { 294 if (worker.timer) { 295 // eslint-disable-next-line no-restricted-globals 296 clearTimeout(worker.timer); 297 } 298 if (data.payload.duration === "reset") { 299 worker.timer = undefined; 300 } 301 else { 302 // eslint-disable-next-line no-restricted-globals 303 worker.timer = setTimeout(killChild, data.payload.duration, data.payload); 304 } 305 break; 306 } 307 case "progress": 308 case "result": { 309 if (worker.currentTasks) { 310 worker.currentTasks.shift(); 311 } 312 totalPassing += data.payload.passing; 313 if (data.payload.errors.length) { 314 errorResults = errorResults.concat(data.payload.errors); 315 passingResults = passingResults.concat(data.payload.passes); 316 failingFiles++; 317 } 318 else { 319 passingResults = passingResults.concat(data.payload.passes); 320 passingFiles++; 321 } 322 newPerfData[hashName(data.payload.task.runner, data.payload.task.file)] = data.payload.duration; 323 324 const progress = (failingFiles + passingFiles) / totalFiles; 325 if (progress >= nextProgress) { 326 while (nextProgress < progress) { 327 nextProgress += progressUpdateInterval; 328 } 329 updateProgress(progress, errorResults.length ? `${errorResults.length} failing` : `${totalPassing} passing`, errorResults.length ? "fail" : undefined); 330 } 331 332 if (data.type === "result") { 333 if (tasks.length === 0) { 334 // No more tasks to distribute 335 worker.process.send({ type: "close" }); 336 closedWorkers++; 337 if (closedWorkers === workerCount) { 338 outputFinalResult(); 339 } 340 return; 341 } 342 // Send tasks in blocks if the tasks are small 343 const taskList = [tasks.pop()!]; 344 while (tasks.length && taskList.reduce((p, c) => p + c.size, 0) < chunkSize) { 345 taskList.push(tasks.pop()!); 346 } 347 worker.currentTasks = taskList; 348 if (taskList.length === 1) { 349 worker.process.send({ type: "test", payload: taskList[0] } as ParallelHostMessage); // TODO: GH#18217 350 } 351 else { 352 worker.process.send({ type: "batch", payload: taskList } as ParallelHostMessage); // TODO: GH#18217 353 } 354 } 355 } 356 } 357 }); 358 workers.push(worker); 359 } 360 361 // It's only really worth doing an initial batching if there are a ton of files to go through (and they have estimates) 362 if (totalFiles > 1000 && batchSize > 0) { 363 console.log("Batching initial test lists..."); 364 const batches: { runner: TestRunnerKind | "unittest", file: string, size: number }[][] = new Array(batchCount); 365 const doneBatching = new Array(batchCount); 366 let scheduledTotal = 0; 367 batcher: while (true) { 368 for (let i = 0; i < batchCount; i++) { 369 if (tasks.length <= workerCount) { // Keep a small reserve even in the suboptimally packed case 370 console.log(`Suboptimal packing detected: no tests remain to be stolen. Reduce packing fraction from ${packfraction} to fix.`); 371 break batcher; 372 } 373 if (doneBatching[i]) { 374 continue; 375 } 376 if (!batches[i]) { 377 batches[i] = []; 378 } 379 const total = batches[i].reduce((p, c) => p + c.size, 0); 380 if (total >= batchSize) { 381 doneBatching[i] = true; 382 continue; 383 } 384 const task = tasks.pop()!; 385 batches[i].push(task); 386 scheduledTotal += task.size; 387 } 388 for (let j = 0; j < batchCount; j++) { 389 if (!doneBatching[j]) { 390 continue batcher; 391 } 392 } 393 break; 394 } 395 const prefix = `Batched into ${batchCount} groups`; 396 if (unknownValue) { 397 console.log(`${prefix}. Unprofiled tests including ${unknownValue} will be run first.`); 398 } 399 else { 400 console.log(`${prefix} with approximate total ${perfData ? "time" : "file sizes"} of ${perfData ? ms(batchSize) : `${Math.floor(batchSize)} bytes`} in each group. (${(scheduledTotal / totalCost * 100).toFixed(1)}% of total tests batched)`); 401 } 402 for (const worker of workers) { 403 const payload = batches.pop(); 404 if (payload) { 405 worker.currentTasks = payload; 406 worker.process.send({ type: "batch", payload }); 407 } 408 else { // Out of batches, send off just one test 409 const payload = tasks.pop()!; 410 ts.Debug.assert(!!payload); // The reserve kept above should ensure there is always an initial task available, even in suboptimal scenarios 411 worker.currentTasks = [payload]; 412 worker.process.send({ type: "test", payload }); 413 } 414 } 415 } 416 else { 417 for (let i = 0; i < workerCount; i++) { 418 const task = tasks.pop()!; 419 workers[i].currentTasks = [task]; 420 workers[i].process.send({ type: "test", payload: task }); 421 } 422 } 423 424 progressBars.enable(); 425 updateProgress(0); 426 let duration: number; 427 let endDate: Date; 428 429 function completeBar() { 430 const isPartitionFail = failingFiles !== 0; 431 const summaryColor = isPartitionFail ? "fail" : "green"; 432 const summarySymbol = isPartitionFail ? Base.symbols.err : Base.symbols.ok; 433 434 const summaryTests = (isPartitionFail ? totalPassing + "/" + (errorResults.length + totalPassing) : totalPassing) + " passing"; 435 const summaryDuration = "(" + ms(duration) + ")"; 436 const savedUseColors = Base.useColors; 437 Base.useColors = !noColors; 438 439 const summary = color(summaryColor, summarySymbol + " " + summaryTests) + " " + color("light", summaryDuration); 440 Base.useColors = savedUseColors; 441 442 updateProgress(1, summary); 443 } 444 445 function updateProgress(percentComplete: number, title?: string, titleColor?: string) { 446 let progressColor = "pending"; 447 if (failingFiles) { 448 progressColor = "fail"; 449 } 450 451 progressBars.update( 452 0, 453 percentComplete, 454 progressColor, 455 title, 456 titleColor 457 ); 458 } 459 460 function outputFinalResult() { 461 function patchStats(stats: Mocha.Stats) { 462 Object.defineProperties(stats, { 463 start: { 464 configurable: true, enumerable: true, 465 get() { return startDate; }, 466 set(_: Date) { /*do nothing*/ } 467 }, 468 end: { 469 configurable: true, enumerable: true, 470 get() { return endDate; }, 471 set(_: Date) { /*do nothing*/ } 472 }, 473 duration: { 474 configurable: true, enumerable: true, 475 get() { return duration; }, 476 set(_: number) { /*do nothing*/ } 477 } 478 }); 479 } 480 481 function rebuildSuite(failures: ErrorInfo[], passes: TestInfo[]) { 482 const root = new RemoteSuite(""); 483 for (const result of [...failures, ...passes] as (ErrorInfo | TestInfo)[]) { 484 getSuite(root, result.name.slice(0, -1)).addTest(new RemoteTest(result)); 485 } 486 return root; 487 function getSuite(parent: RemoteSuite, titlePath: string[]): Mocha.Suite { 488 const title = titlePath[0]; 489 let suite = parent.suiteMap.get(title); 490 if (!suite) parent.addSuite(suite = new RemoteSuite(title)); 491 return titlePath.length === 1 ? suite : getSuite(suite, titlePath.slice(1)); 492 } 493 } 494 495 function rebuildError(result: ErrorInfo) { 496 const error = new Error(result.error); 497 error.stack = result.stack; 498 return error; 499 } 500 501 function replaySuite(runner: Mocha.Runner, suite: RemoteSuite) { 502 runner.emit("suite", suite); 503 for (const test of suite.tests) { 504 replayTest(runner, test as RemoteTest); 505 } 506 for (const child of suite.suites) { 507 replaySuite(runner, child as RemoteSuite); 508 } 509 runner.emit("suite end", suite); 510 } 511 512 function replayTest(runner: Mocha.Runner, test: RemoteTest) { 513 runner.emit("test", test); 514 if (test.isFailed()) { 515 runner.emit("fail", test, "error" in test.info ? rebuildError(test.info) : new Error("Unknown error")); // eslint-disable-line no-in-operator 516 } 517 else { 518 runner.emit("pass", test); 519 } 520 runner.emit("test end", test); 521 } 522 523 endDate = new Date(); 524 duration = +endDate - +startDate; 525 completeBar(); 526 progressBars.disable(); 527 528 const replayRunner = new Mocha.Runner(new Mocha.Suite(""), /*delay*/ false); 529 replayRunner.started = true; 530 const createStatsCollector = require("mocha/lib/stats-collector"); 531 createStatsCollector(replayRunner); // manually init stats collector like mocha.run would 532 533 const consoleReporter = new Base(replayRunner); 534 patchStats(consoleReporter.stats); 535 536 let xunitReporter: import("mocha").reporters.XUnit | undefined; 537 let failedTestReporter: import("../../../scripts/failed-tests") | undefined; 538 if (process.env.CI === "true") { 539 xunitReporter = new Mocha.reporters.XUnit(replayRunner, { 540 reporterOptions: { 541 suiteName: "Tests", 542 output: "./TEST-results.xml" 543 } 544 }); 545 patchStats(xunitReporter.stats); 546 xunitReporter.write(`<?xml version="1.0" encoding="UTF-8"?>\n`); 547 } 548 else { 549 failedTestReporter = new FailedTestReporter(replayRunner, { 550 reporterOptions: { 551 file: path.resolve(".failed-tests"), 552 keepFailed: Harness.keepFailed // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier 553 } 554 }); 555 } 556 557 const savedUseColors = Base.useColors; 558 if (noColors) Base.useColors = false; 559 replayRunner.started = true; 560 replayRunner.emit("start"); 561 replaySuite(replayRunner, rebuildSuite(errorResults, passingResults)); 562 replayRunner.emit("end"); 563 consoleReporter.epilogue(); 564 if (noColors) Base.useColors = savedUseColors; 565 566 // eslint-disable-next-line no-null/no-null 567 IO.writeFile(perfdataFileName(configOption), JSON.stringify(newPerfData, null, 4)); 568 569 if (xunitReporter) { 570 xunitReporter.done(errorResults.length, failures => process.exit(failures)); 571 } 572 else if (failedTestReporter) { 573 failedTestReporter.done(errorResults.length, failures => process.exit(failures)); 574 } 575 else { 576 process.exit(errorResults.length); 577 } 578 } 579 } 580 581 function fill(ch: string, size: number) { 582 let s = ""; 583 while (s.length < size) { 584 s += ch; 585 } 586 587 return s.length > size ? s.substr(0, size) : s; 588 } 589 590 function minMax(value: number, min: number, max: number) { 591 if (value < min) return min; 592 if (value > max) return max; 593 return value; 594 } 595 596 function shimDiscoveryInterface(context: Mocha.MochaGlobals) { 597 shimNoopTestInterface(context); 598 599 const perfData = readSavedPerfData(configOption); 600 context.describe = addSuite as Mocha.SuiteFunction; 601 context.it = addSuite as Mocha.TestFunction; 602 603 function addSuite(title: string) { 604 // Note, sub-suites are not indexed (we assume such granularity is not required) 605 let size = 0; 606 if (perfData) { 607 size = perfData[hashName("unittest", title)]; 608 if (size === undefined) { 609 newTasks.push({ runner: "unittest", file: title, size: 0 }); 610 unknownValue = title; 611 return; 612 } 613 } 614 tasks.push({ runner: "unittest", file: title, size }); 615 totalCost += size; 616 } 617 } 618 619 if (runUnitTests) { 620 shimDiscoveryInterface(global); 621 } 622 else { 623 shimNoopTestInterface(global); 624 } 625 626 // eslint-disable-next-line no-restricted-globals 627 setTimeout(() => startDelayed(perfData, totalCost), 0); // Do real startup on next tick, so all unit tests have been collected 628 } 629} 630