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