• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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