• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1namespace Harness.Parallel.Worker {
2    export function start() {
3        function hookUncaughtExceptions() {
4            if (!exceptionsHooked) {
5                process.on("uncaughtException", handleUncaughtException);
6                process.on("unhandledRejection", handleUncaughtException);
7                exceptionsHooked = true;
8            }
9        }
10
11        function unhookUncaughtExceptions() {
12            if (exceptionsHooked) {
13                process.removeListener("uncaughtException", handleUncaughtException);
14                process.removeListener("unhandledRejection", handleUncaughtException);
15                exceptionsHooked = false;
16            }
17        }
18
19        let exceptionsHooked = false;
20        hookUncaughtExceptions();
21
22        // Capitalization is aligned with the global `Mocha` namespace for typespace/namespace references.
23        const Mocha = require("mocha") as typeof import("mocha");
24
25        /**
26         * Mixin helper.
27         * @param base The base class constructor.
28         * @param mixins The mixins to apply to the constructor.
29         */
30        function mixin<T extends new (...args: any[]) => any>(base: T, ...mixins: ((klass: T) => T)[]) {
31            for (const mixin of mixins) {
32                base = mixin(base);
33            }
34            return base;
35        }
36
37        /**
38         * Mixes in overrides for `resetTimeout` and `clearTimeout` to support parallel test execution in a worker.
39         */
40        function Timeout<T extends typeof Mocha.Runnable>(base: T) {
41            return class extends (base as typeof Mocha.Runnable) {
42                resetTimeout() {
43                    this.clearTimeout();
44                    if (this.timeout() > 0) {
45                        sendMessage({ type: "timeout", payload: { duration: this.timeout() || 1e9 } });
46                        this.timer = true;
47                    }
48                }
49                clearTimeout() {
50                    if (this.timer) {
51                        sendMessage({ type: "timeout", payload: { duration: "reset" } });
52                        this.timer = false;
53                    }
54                }
55            } as T;
56        }
57
58        /**
59         * Mixes in an override for `clone` to support parallel test execution in a worker.
60         */
61        function Clone<T extends typeof Mocha.Suite | typeof Mocha.Test>(base: T) {
62            return class extends (base as new (...args: any[]) => { clone(): any; }) {
63                clone() {
64                    const cloned = super.clone();
65                    Object.setPrototypeOf(cloned, this.constructor.prototype);
66                    return cloned;
67                }
68            } as T;
69        }
70
71        /**
72         * A `Mocha.Suite` subclass to support parallel test execution in a worker.
73         */
74        class Suite extends mixin(Mocha.Suite, Clone) {
75            _createHook(title: string, fn?: Mocha.Func | Mocha.AsyncFunc) {
76                const hook = super._createHook(title, fn);
77                Object.setPrototypeOf(hook, Hook.prototype);
78                return hook;
79            }
80        }
81
82        /**
83         * A `Mocha.Hook` subclass to support parallel test execution in a worker.
84         */
85        class Hook extends mixin(Mocha.Hook, Timeout) {
86        }
87
88        /**
89         * A `Mocha.Test` subclass to support parallel test execution in a worker.
90         */
91        class Test extends mixin(Mocha.Test, Timeout, Clone) {
92        }
93
94        /**
95         * Shims a 'bdd'-style test interface to support parallel test execution in a worker.
96         * @param rootSuite The root suite.
97         * @param context The test context (usually the NodeJS `global` object).
98         */
99        function shimTestInterface(rootSuite: Mocha.Suite, context: Mocha.MochaGlobals) {
100            const suites = [rootSuite];
101            context.before = (title: string | Mocha.Func | Mocha.AsyncFunc, fn?: Mocha.Func | Mocha.AsyncFunc) => { suites[0].beforeAll(title as string, fn); };
102            context.after = (title: string | Mocha.Func | Mocha.AsyncFunc, fn?: Mocha.Func | Mocha.AsyncFunc) => { suites[0].afterAll(title as string, fn); };
103            context.beforeEach = (title: string | Mocha.Func | Mocha.AsyncFunc, fn?: Mocha.Func | Mocha.AsyncFunc) => { suites[0].beforeEach(title as string, fn); };
104            context.afterEach = (title: string | Mocha.Func | Mocha.AsyncFunc, fn?: Mocha.Func | Mocha.AsyncFunc) => { suites[0].afterEach(title as string, fn); };
105            context.describe = context.context = ((title: string, fn: (this: Mocha.Suite) => void) => addSuite(title, fn)) as Mocha.SuiteFunction;
106            context.describe.skip = context.xdescribe = context.xcontext = (title: string) => addSuite(title, /*fn*/ undefined);
107            context.describe.only = (title: string, fn?: (this: Mocha.Suite) => void) => addSuite(title, fn);
108            context.it = context.specify = ((title: string | Mocha.Func | Mocha.AsyncFunc, fn?: Mocha.Func | Mocha.AsyncFunc) => addTest(title, fn)) as Mocha.TestFunction;
109            context.it.skip = context.xit = context.xspecify = (title: string | Mocha.Func | Mocha.AsyncFunc) => addTest(typeof title === "function" ? title.name : title, /*fn*/ undefined);
110            context.it.only = (title: string | Mocha.Func | Mocha.AsyncFunc, fn?: Mocha.Func | Mocha.AsyncFunc) => addTest(title, fn);
111
112            function addSuite(title: string, fn: ((this: Mocha.Suite) => void) | undefined): Mocha.Suite {
113                const suite = new Suite(title, suites[0].ctx);
114                suites[0].addSuite(suite);
115                suite.pending = !fn;
116                suites.unshift(suite);
117                if (fn) {
118                    fn.call(suite);
119                }
120                suites.shift();
121                return suite;
122            }
123
124            function addTest(title: string | Mocha.Func | Mocha.AsyncFunc, fn: Mocha.Func | Mocha.AsyncFunc | undefined): Mocha.Test {
125                if (typeof title === "function") {
126                    fn = title;
127                    title = fn.name;
128                }
129                const test = new Test(title, suites[0].pending ? undefined : fn);
130                suites[0].addTest(test);
131                return test;
132            }
133        }
134
135        /**
136         * Run the tests in the requested task.
137         */
138        function runTests(task: Task, fn: (payload: TaskResult) => void) {
139            if (task.runner === "unittest") {
140                return executeUnitTests(task, fn);
141            }
142            else {
143                return runFileTests(task, fn);
144            }
145        }
146
147        function executeUnitTests(task: UnitTestTask, fn: (payload: TaskResult) => void) {
148            if (!unitTestSuiteMap && unitTestSuite.suites.length) {
149                unitTestSuiteMap = new ts.Map<string, Mocha.Suite>();
150                for (const suite of unitTestSuite.suites) {
151                    unitTestSuiteMap.set(suite.title, suite);
152                }
153            }
154            if (!unitTestTestMap && unitTestSuite.tests.length) {
155                unitTestTestMap = new ts.Map<string, Mocha.Test>();
156                for (const test of unitTestSuite.tests) {
157                    unitTestTestMap.set(test.title, test);
158                }
159            }
160
161            if (!unitTestSuiteMap && !unitTestTestMap) {
162                throw new Error(`Asked to run unit test ${task.file}, but no unit tests were discovered!`);
163            }
164
165            let suite = unitTestSuiteMap.get(task.file);
166            const test = unitTestTestMap.get(task.file);
167            if (!suite && !test) {
168                throw new Error(`Unit test with name "${task.file}" was asked to be run, but such a test does not exist!`);
169            }
170
171            const root = new Suite("", new Mocha.Context());
172            root.timeout(globalTimeout || 40_000);
173            if (suite) {
174                root.addSuite(suite);
175                Object.setPrototypeOf(suite.ctx, root.ctx);
176            }
177            else if (test) {
178                const newSuite = new Suite("", new Mocha.Context());
179                newSuite.addTest(test);
180                root.addSuite(newSuite);
181                Object.setPrototypeOf(newSuite.ctx, root.ctx);
182                Object.setPrototypeOf(test.ctx, root.ctx);
183                test.parent = newSuite;
184                suite = newSuite;
185            }
186
187            runSuite(task, suite!, payload => {
188                suite!.parent = unitTestSuite;
189                Object.setPrototypeOf(suite!.ctx, unitTestSuite.ctx);
190                fn(payload);
191            });
192        }
193
194        function runFileTests(task: RunnerTask, fn: (result: TaskResult) => void) {
195            let instance = runners.get(task.runner);
196            if (!instance) runners.set(task.runner, instance = createRunner(task.runner));
197            instance.tests = [task.file];
198
199            const suite = new Suite("", new Mocha.Context());
200            suite.timeout(globalTimeout || 40_000);
201
202            shimTestInterface(suite, global);
203            instance.initializeTests();
204
205            runSuite(task, suite, fn);
206        }
207
208        function runSuite(task: Task, suite: Mocha.Suite, fn: (result: TaskResult) => void) {
209            const errors: ErrorInfo[] = [];
210            const passes: TestInfo[] = [];
211            const start = +new Date();
212            const runner = new Mocha.Runner(suite, /*delay*/ false);
213
214            runner
215                .on("start", () => {
216                    unhookUncaughtExceptions(); // turn off global uncaught handling
217                })
218                .on("pass", (test: Mocha.Test) => {
219                    passes.push({ name: test.titlePath() });
220                })
221                .on("fail", (test: Mocha.Test | Mocha.Hook, err: any) => {
222                    errors.push({ name: test.titlePath(), error: err.message, stack: err.stack });
223                })
224                .on("end", () => {
225                    hookUncaughtExceptions();
226                    runner.dispose();
227                })
228                .run(() => {
229                    fn({ task, errors, passes, passing: passes.length, duration: +new Date() - start });
230                });
231        }
232
233        /**
234         * Validates a message received from the host is well-formed.
235         */
236        function validateHostMessage(message: ParallelHostMessage) {
237            switch (message.type) {
238                case "test": return validateTest(message.payload);
239                case "batch": return validateBatch(message.payload);
240                case "close": return true;
241                default: return false;
242            }
243        }
244
245        /**
246         * Validates a test task is well formed.
247         */
248        function validateTest(task: Task) {
249            return !!task && !!task.runner && !!task.file;
250        }
251
252        /**
253         * Validates a batch of test tasks are well formed.
254         */
255        function validateBatch(tasks: Task[]) {
256            return !!tasks && Array.isArray(tasks) && tasks.length > 0 && tasks.every(validateTest);
257        }
258
259        function processHostMessage(message: ParallelHostMessage) {
260            if (!validateHostMessage(message)) {
261                console.log("Invalid message:", message);
262                return;
263            }
264
265            switch (message.type) {
266                case "test": return processTest(message.payload, /*last*/ true);
267                case "batch": return processBatch(message.payload);
268                case "close": return process.exit(0);
269            }
270        }
271
272        function processTest(task: Task, last: boolean, fn?: () => void) {
273            runTests(task, payload => {
274                sendMessage(last ? { type: "result", payload } : { type: "progress", payload });
275                if (fn) fn();
276            });
277        }
278
279        function processBatch(tasks: Task[], fn?: () => void) {
280            const next = () => {
281                const task = tasks.shift();
282                if (task) return processTest(task, tasks.length === 0, next);
283                if (fn) fn();
284            };
285            next();
286        }
287
288        function handleUncaughtException(err: any) {
289            const error = err instanceof Error ? err : new Error("" + err);
290            sendMessage({ type: "error", payload: { error: error.message, stack: error.stack! } });
291        }
292
293        function sendMessage(message: ParallelClientMessage) {
294            process.send!(message);
295        }
296
297        // A cache of test harness Runner instances.
298        const runners = new ts.Map<string, RunnerBase>();
299
300        // The root suite for all unit tests.
301        let unitTestSuite: Suite;
302        let unitTestSuiteMap: ts.ESMap<string, Mocha.Suite>;
303        // (Unit) Tests directly within the root suite
304        let unitTestTestMap: ts.ESMap<string, Mocha.Test>;
305
306        if (runUnitTests) {
307            unitTestSuite = new Suite("", new Mocha.Context());
308            unitTestSuite.timeout(globalTimeout || 40_000);
309            shimTestInterface(unitTestSuite, global);
310        }
311        else {
312            // ensure unit tests do not get run
313            shimNoopTestInterface(global);
314        }
315
316        process.on("message", processHostMessage);
317    }
318}
319