UNPKG

21 kBJavaScriptView Raw
1import exitHook from 'async-exit-hook';
2import logger from '@wdio/logger';
3import { validateConfig } from '@wdio/config';
4import { ConfigParser } from '@wdio/config/node';
5import { initializePlugin, initializeLauncherService, sleep, enableFileLogging } from '@wdio/utils';
6import { setupDriver, setupBrowser } from '@wdio/utils/node';
7import CLInterface from './interface.js';
8import { runLauncherHook, runOnCompleteHook, runServiceHook } from './utils.js';
9import { TESTRUNNER_DEFAULTS, WORKER_GROUPLOGS_MESSAGES } from './constants.js';
10const log = logger('@wdio/cli:launcher');
11class Launcher {
12 _configFilePath;
13 _args;
14 _isWatchMode;
15 configParser;
16 isMultiremote = false;
17 isParallelMultiremote = false;
18 runner;
19 interface;
20 _exitCode = 0;
21 _hasTriggeredExitRoutine = false;
22 _schedule = [];
23 _rid = [];
24 _runnerStarted = 0;
25 _runnerFailed = 0;
26 _launcher;
27 _resolve;
28 constructor(_configFilePath, _args = {}, _isWatchMode = false) {
29 this._configFilePath = _configFilePath;
30 this._args = _args;
31 this._isWatchMode = _isWatchMode;
32 this.configParser = new ConfigParser(this._configFilePath, this._args);
33 }
34 /**
35 * run sequence
36 * @return {Promise} that only gets resolved with either an exitCode or an error
37 */
38 async run() {
39 await this.configParser.initialize(this._args);
40 const config = this.configParser.getConfig();
41 /**
42 * assign parsed autocompile options into args so it can be used within the worker
43 * without having to read the config again
44 */
45 this._args.autoCompileOpts = config.autoCompileOpts;
46 const capabilities = this.configParser.getCapabilities();
47 this.isParallelMultiremote = Array.isArray(capabilities) &&
48 capabilities.every(cap => Object.values(cap).length > 0 && Object.values(cap).every(c => typeof c === 'object' && c.capabilities));
49 this.isMultiremote = this.isParallelMultiremote || !Array.isArray(capabilities);
50 validateConfig(TESTRUNNER_DEFAULTS, { ...config, capabilities });
51 await enableFileLogging(config.outputDir);
52 logger.setLogLevelsConfig(config.logLevels, config.logLevel);
53 /**
54 * For Parallel-Multiremote, only get the specs and excludes from the first object
55 */
56 const totalWorkerCnt = Array.isArray(capabilities)
57 ? capabilities
58 .map((c) => {
59 if (this.isParallelMultiremote) {
60 const keys = Object.keys(c);
61 return this.configParser.getSpecs(c[keys[0]].capabilities.specs, c[keys[0]].capabilities.exclude).length;
62 }
63 return this.configParser.getSpecs(c.specs, c.exclude).length;
64 })
65 .reduce((a, b) => a + b, 0)
66 : 1;
67 this.interface = new CLInterface(config, totalWorkerCnt, this._isWatchMode);
68 config.runnerEnv.FORCE_COLOR = Number(this.interface.hasAnsiSupport);
69 const [runnerName, runnerOptions] = Array.isArray(config.runner) ? config.runner : [config.runner, {}];
70 const Runner = (await initializePlugin(runnerName, 'runner')).default;
71 this.runner = new Runner(runnerOptions, config);
72 /**
73 * catches ctrl+c event
74 */
75 exitHook(this._exitHandler.bind(this));
76 let exitCode = 0;
77 let error = undefined;
78 try {
79 const caps = this.configParser.getCapabilities();
80 const { ignoredWorkerServices, launcherServices } = await initializeLauncherService(config, caps);
81 this._launcher = launcherServices;
82 this._args.ignoredWorkerServices = ignoredWorkerServices;
83 /**
84 * run pre test tasks for runner plugins
85 * (e.g. deploy Lambda function to AWS)
86 */
87 await this.runner.initialize();
88 /**
89 * run onPrepare hook
90 */
91 log.info('Run onPrepare hook');
92 await runLauncherHook(config.onPrepare, config, caps);
93 await runServiceHook(this._launcher, 'onPrepare', config, caps);
94 /**
95 * pre-configure necessary driver for worker threads
96 */
97 await Promise.all([
98 setupDriver(config, caps),
99 setupBrowser(config, caps)
100 ]);
101 exitCode = await this._runMode(config, caps);
102 /**
103 * run onComplete hook
104 * Even if it fails we still want to see result and end logger stream.
105 * Also ensure that user hooks are run before service hooks so that e.g.
106 * a user can use plugin service, e.g. shared store service is still
107 * available running hooks in this order
108 */
109 log.info('Run onComplete hook');
110 const onCompleteResults = await runOnCompleteHook(config.onComplete, config, caps, exitCode, this.interface.result);
111 await runServiceHook(this._launcher, 'onComplete', exitCode, config, caps);
112 // if any of the onComplete hooks failed, update the exit code
113 exitCode = onCompleteResults.includes(1) ? 1 : exitCode;
114 await logger.waitForBuffer();
115 this.interface.finalise();
116 }
117 catch (err) {
118 error = err;
119 }
120 finally {
121 if (!this._hasTriggeredExitRoutine) {
122 this._hasTriggeredExitRoutine = true;
123 const passesCodeCoverage = await this.runner.shutdown();
124 if (!passesCodeCoverage) {
125 exitCode = exitCode || 1;
126 }
127 }
128 }
129 if (error) {
130 this.interface.logHookError(error);
131 throw error;
132 }
133 return exitCode;
134 }
135 /**
136 * run without triggering onPrepare/onComplete hooks
137 */
138 _runMode(config, caps) {
139 /**
140 * fail if no caps were found
141 */
142 if (!caps) {
143 return new Promise((resolve) => {
144 log.error('Missing capabilities, exiting with failure');
145 return resolve(1);
146 });
147 }
148 /**
149 * avoid retries in watch mode
150 */
151 const specFileRetries = this._isWatchMode ? 0 : config.specFileRetries;
152 /**
153 * schedule test runs
154 */
155 let cid = 0;
156 if (this.isMultiremote && !this.isParallelMultiremote) {
157 /**
158 * Multiremote mode
159 */
160 this._schedule.push({
161 cid: cid++,
162 caps: caps,
163 specs: this._formatSpecs(caps, specFileRetries),
164 availableInstances: config.maxInstances || 1,
165 runningInstances: 0
166 });
167 }
168 else {
169 /**
170 * Regular mode & Parallel Multiremote
171 */
172 for (const capabilities of caps) {
173 /**
174 * when using browser runner we only allow one session per browser
175 */
176 const availableInstances = this.isParallelMultiremote ? config.maxInstances || 1 : config.runner === 'browser'
177 ? 1
178 : capabilities.maxInstances || capabilities['wdio:maxInstances'] || config.maxInstancesPerCapability;
179 this._schedule.push({
180 cid: cid++,
181 caps: capabilities,
182 specs: this._formatSpecs(capabilities, specFileRetries),
183 availableInstances,
184 runningInstances: 0
185 });
186 }
187 }
188 return new Promise((resolve) => {
189 this._resolve = resolve;
190 /**
191 * fail if no specs were found or specified
192 */
193 if (Object.values(this._schedule).reduce((specCnt, schedule) => specCnt + schedule.specs.length, 0) === 0) {
194 const { total, current } = config.shard;
195 if (total > 1) {
196 log.info(`No specs to execute in shard ${current}/${total}, exiting!`);
197 return resolve(0);
198 }
199 log.error('No specs found to run, exiting with failure');
200 return resolve(1);
201 }
202 /**
203 * return immediately if no spec was run
204 */
205 if (this._runSpecs()) {
206 resolve(0);
207 }
208 });
209 }
210 /**
211 * Format the specs into an array of objects with files and retries
212 */
213 _formatSpecs(capabilities, specFileRetries) {
214 let caps;
215 if ('alwaysMatch' in capabilities) {
216 caps = capabilities.alwaysMatch;
217 }
218 else if (typeof Object.keys(capabilities)[0] === 'object' && 'capabilities' in capabilities[Object.keys(capabilities)[0]]) {
219 caps = {};
220 }
221 else {
222 caps = capabilities;
223 }
224 const specs = caps.specs || caps['wdio:specs'];
225 const excludes = caps.exclude || caps['wdio:exclude'];
226 const files = this.configParser.getSpecs(specs, excludes);
227 return files.map((file) => {
228 if (typeof file === 'string') {
229 return { files: [file], retries: specFileRetries };
230 }
231 else if (Array.isArray(file)) {
232 return { files: file, retries: specFileRetries };
233 }
234 log.warn('Unexpected entry in specs that is neither string nor array: ', file);
235 // Returning an empty structure to avoid undefined
236 return { files: [], retries: specFileRetries };
237 });
238 }
239 /**
240 * run multiple single remote tests
241 * @return {Boolean} true if all specs have been run and all instances have finished
242 */
243 _runSpecs() {
244 /**
245 * stop spawning new processes when CTRL+C was triggered
246 */
247 if (this._hasTriggeredExitRoutine) {
248 return true;
249 }
250 const config = this.configParser.getConfig();
251 while (this._getNumberOfRunningInstances() < config.maxInstances) {
252 const schedulableCaps = this._schedule
253 /**
254 * bail if number of errors exceeds allowed
255 */
256 .filter(() => {
257 const filter = typeof config.bail !== 'number' || config.bail < 1 ||
258 config.bail > this._runnerFailed;
259 /**
260 * clear number of specs when filter is false
261 */
262 if (!filter) {
263 this._schedule.forEach((t) => { t.specs = []; });
264 }
265 return filter;
266 })
267 /**
268 * make sure complete number of running instances is not higher than general maxInstances number
269 */
270 .filter(() => this._getNumberOfRunningInstances() < config.maxInstances)
271 /**
272 * make sure the capability has available capacities
273 */
274 .filter((a) => a.availableInstances > 0)
275 /**
276 * make sure capability has still caps to run
277 */
278 .filter((a) => a.specs.length > 0)
279 /**
280 * make sure we are running caps with less running instances first
281 */
282 .sort((a, b) => a.runningInstances - b.runningInstances);
283 /**
284 * continue if no capability were schedulable
285 */
286 if (schedulableCaps.length === 0) {
287 break;
288 }
289 const specs = schedulableCaps[0].specs.shift();
290 this._startInstance(specs.files, schedulableCaps[0].caps, schedulableCaps[0].cid, specs.rid, specs.retries);
291 schedulableCaps[0].availableInstances--;
292 schedulableCaps[0].runningInstances++;
293 }
294 return this._getNumberOfRunningInstances() === 0 && this._getNumberOfSpecsLeft() === 0;
295 }
296 /**
297 * gets number of all running instances
298 * @return {number} number of running instances
299 */
300 _getNumberOfRunningInstances() {
301 return this._schedule.map((a) => a.runningInstances).reduce((a, b) => a + b);
302 }
303 /**
304 * get number of total specs left to complete whole suites
305 * @return {number} specs left to complete suite
306 */
307 _getNumberOfSpecsLeft() {
308 return this._schedule.map((a) => a.specs.length).reduce((a, b) => a + b);
309 }
310 /**
311 * Start instance in a child process.
312 * @param {Array} specs Specs to run
313 * @param {number} cid Capabilities ID
314 * @param {string} rid Runner ID override
315 * @param {number} retries Number of retries remaining
316 */
317 async _startInstance(specs, caps, cid, rid, retries) {
318 if (!this.runner || !this.interface) {
319 throw new Error('Internal Error: no runner initialized, call run() first');
320 }
321 const config = this.configParser.getConfig();
322 // wait before retrying the spec file
323 if (typeof config.specFileRetriesDelay === 'number' && config.specFileRetries > 0 && config.specFileRetries !== retries) {
324 await sleep(config.specFileRetriesDelay * 1000);
325 }
326 // Retried tests receive the cid of the failing test as rid
327 // so they can run with the same cid of the failing test.
328 const runnerId = rid || this._getRunnerId(cid);
329 const processNumber = this._runnerStarted + 1;
330 // process.debugPort defaults to 5858 and is set even when process
331 // is not being debugged.
332 const debugArgs = [];
333 let debugType;
334 let debugHost = '';
335 const debugPort = process.debugPort;
336 for (const i in process.execArgv) {
337 const debugArgs = process.execArgv[i].match('--(debug|inspect)(?:-brk)?(?:=(.*):)?');
338 if (debugArgs) {
339 const [, type, host] = debugArgs;
340 if (type) {
341 debugType = type;
342 }
343 if (host) {
344 debugHost = `${host}:`;
345 }
346 }
347 }
348 if (debugType) {
349 debugArgs.push(`--${debugType}=${debugHost}${(debugPort + processNumber)}`);
350 }
351 // if you would like to add --debug-brk, use a different port, etc...
352 const capExecArgs = [...(config.execArgv || [])];
353 // The default value for child.fork execArgs is process.execArgs,
354 // so continue to use this unless another value is specified in config.
355 const defaultArgs = (capExecArgs.length) ? process.execArgv : [];
356 // If an arg appears multiple times the last occurrence is used
357 const execArgv = [...defaultArgs, ...debugArgs, ...capExecArgs];
358 // bump up worker count
359 this._runnerStarted++;
360 // run worker hook to allow modify runtime and capabilities of a specific worker
361 log.info('Run onWorkerStart hook');
362 await runLauncherHook(config.onWorkerStart, runnerId, caps, specs, this._args, execArgv)
363 .catch((error) => this._workerHookError(error));
364 await runServiceHook(this._launcher, 'onWorkerStart', runnerId, caps, specs, this._args, execArgv)
365 .catch((error) => this._workerHookError(error));
366 // prefer launcher settings in capabilities over general launcher
367 const worker = await this.runner.run({
368 cid: runnerId,
369 command: 'run',
370 configFile: this._configFilePath,
371 args: {
372 ...this._args,
373 ...(config?.autoCompileOpts
374 ? { autoCompileOpts: config.autoCompileOpts }
375 : {}),
376 /**
377 * Pass on user and key values to ensure they are available in the worker process when using
378 * environment variables that were locally exported but not part of the environment.
379 */
380 user: config.user,
381 key: config.key
382 },
383 caps,
384 specs,
385 execArgv,
386 retries
387 });
388 worker.on('message', this.interface.onMessage.bind(this.interface));
389 worker.on('error', this.interface.onMessage.bind(this.interface));
390 worker.on('exit', (code) => {
391 if (!this.configParser.getConfig().groupLogsByTestSpec) {
392 return;
393 }
394 if (code.exitCode === 0) {
395 console.log(WORKER_GROUPLOGS_MESSAGES.normalExit(code.cid));
396 }
397 else {
398 console.log(WORKER_GROUPLOGS_MESSAGES.exitWithError(code.cid));
399 }
400 worker.logsAggregator.forEach((logLine) => {
401 console.log(logLine.replace(new RegExp('\\n$'), ''));
402 });
403 });
404 worker.on('exit', this._endHandler.bind(this));
405 }
406 _workerHookError(error) {
407 if (!this.interface) {
408 throw new Error('Internal Error: no interface initialized, call run() first');
409 }
410 this.interface.logHookError(error);
411 if (this._resolve) {
412 this._resolve(1);
413 }
414 }
415 /**
416 * generates a runner id
417 * @param {number} cid capability id (unique identifier for a capability)
418 * @return {String} runner id (combination of cid and test id e.g. 0a, 0b, 1a, 1b ...)
419 */
420 _getRunnerId(cid) {
421 if (!this._rid[cid]) {
422 this._rid[cid] = 0;
423 }
424 return `${cid}-${this._rid[cid]++}`;
425 }
426 /**
427 * Close test runner process once all child processes have exited
428 * @param {number} cid Capabilities ID
429 * @param {number} exitCode exit code of child process
430 * @param {Array} specs Specs that were run
431 * @param {number} retries Number or retries remaining
432 */
433 async _endHandler({ cid: rid, exitCode, specs, retries }) {
434 const passed = this._isWatchModeHalted() || exitCode === 0;
435 if (!passed && retries > 0) {
436 // Default is true, so test for false explicitly
437 const requeue = this.configParser.getConfig().specFileRetriesDeferred !== false ? 'push' : 'unshift';
438 this._schedule[parseInt(rid, 10)].specs[requeue]({ files: specs, retries: retries - 1, rid });
439 }
440 else {
441 this._exitCode = this._isWatchModeHalted() ? 0 : this._exitCode || exitCode;
442 this._runnerFailed += !passed ? 1 : 0;
443 }
444 /**
445 * avoid emitting job:end if watch mode has been stopped by user
446 */
447 if (!this._isWatchModeHalted() && this.interface) {
448 this.interface.emit('job:end', { cid: rid, passed, retries });
449 }
450 /**
451 * Update schedule now this process has ended
452 * get cid (capability id) from rid (runner id)
453 */
454 const cid = parseInt(rid, 10);
455 this._schedule[cid].availableInstances++;
456 this._schedule[cid].runningInstances--;
457 log.info('Run onWorkerEnd hook');
458 const config = this.configParser.getConfig();
459 await runLauncherHook(config.onWorkerEnd, rid, exitCode, specs, retries)
460 .catch((error) => this._workerHookError(error));
461 await runServiceHook(this._launcher, 'onWorkerEnd', rid, exitCode, specs, retries)
462 .catch((error) => this._workerHookError(error));
463 /**
464 * do nothing if
465 * - there are specs to be executed
466 * - we are running watch mode
467 */
468 const shouldRunSpecs = this._runSpecs();
469 const inWatchMode = this._isWatchMode && !this._hasTriggeredExitRoutine;
470 if (!shouldRunSpecs || inWatchMode) {
471 /**
472 * print reporter results when in watch mode
473 */
474 if (inWatchMode) {
475 this.interface?.finalise();
476 }
477 return;
478 }
479 if (this._resolve) {
480 this._resolve(passed ? this._exitCode : 1);
481 }
482 }
483 /**
484 * We need exitHandler to catch SIGINT / SIGTERM events.
485 * Make sure all started selenium sessions get closed properly and prevent
486 * having dead driver processes. To do so let the runner end its Selenium
487 * session first before killing
488 */
489 _exitHandler(callback) {
490 if (!callback || !this.runner || !this.interface) {
491 return;
492 }
493 if (this._hasTriggeredExitRoutine) {
494 return callback(true);
495 }
496 this._hasTriggeredExitRoutine = true;
497 this.interface.sigintTrigger();
498 return this.runner.shutdown().then(callback);
499 }
500 /**
501 * returns true if user stopped watch mode, ex with ctrl+c
502 * @returns {boolean}
503 */
504 _isWatchModeHalted() {
505 return this._isWatchMode && this._hasTriggeredExitRoutine;
506 }
507}
508export default Launcher;