1 | import exitHook from 'async-exit-hook';
|
2 | import logger from '@wdio/logger';
|
3 | import { validateConfig } from '@wdio/config';
|
4 | import { ConfigParser } from '@wdio/config/node';
|
5 | import { initializePlugin, initializeLauncherService, sleep, enableFileLogging } from '@wdio/utils';
|
6 | import { setupDriver, setupBrowser } from '@wdio/utils/node';
|
7 | import CLInterface from './interface.js';
|
8 | import { runLauncherHook, runOnCompleteHook, runServiceHook } from './utils.js';
|
9 | import { TESTRUNNER_DEFAULTS, WORKER_GROUPLOGS_MESSAGES } from './constants.js';
|
10 | const log = logger('@wdio/cli:launcher');
|
11 | class 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 |
|
36 |
|
37 |
|
38 | async run() {
|
39 | await this.configParser.initialize(this._args);
|
40 | const config = this.configParser.getConfig();
|
41 | |
42 |
|
43 |
|
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 |
|
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 |
|
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 |
|
85 |
|
86 |
|
87 | await this.runner.initialize();
|
88 | |
89 |
|
90 |
|
91 | log.info('Run onPrepare hook');
|
92 | await runLauncherHook(config.onPrepare, config, caps);
|
93 | await runServiceHook(this._launcher, 'onPrepare', config, caps);
|
94 | |
95 |
|
96 |
|
97 | await Promise.all([
|
98 | setupDriver(config, caps),
|
99 | setupBrowser(config, caps)
|
100 | ]);
|
101 | exitCode = await this._runMode(config, caps);
|
102 | |
103 |
|
104 |
|
105 |
|
106 |
|
107 |
|
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 |
|
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 |
|
137 |
|
138 | _runMode(config, caps) {
|
139 | |
140 |
|
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 |
|
150 |
|
151 | const specFileRetries = this._isWatchMode ? 0 : config.specFileRetries;
|
152 | |
153 |
|
154 |
|
155 | let cid = 0;
|
156 | if (this.isMultiremote && !this.isParallelMultiremote) {
|
157 | |
158 |
|
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 |
|
171 |
|
172 | for (const capabilities of caps) {
|
173 | |
174 |
|
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 |
|
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 |
|
204 |
|
205 | if (this._runSpecs()) {
|
206 | resolve(0);
|
207 | }
|
208 | });
|
209 | }
|
210 | |
211 |
|
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 |
|
236 | return { files: [], retries: specFileRetries };
|
237 | });
|
238 | }
|
239 | |
240 |
|
241 |
|
242 |
|
243 | _runSpecs() {
|
244 | |
245 |
|
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 |
|
255 |
|
256 | .filter(() => {
|
257 | const filter = typeof config.bail !== 'number' || config.bail < 1 ||
|
258 | config.bail > this._runnerFailed;
|
259 | |
260 |
|
261 |
|
262 | if (!filter) {
|
263 | this._schedule.forEach((t) => { t.specs = []; });
|
264 | }
|
265 | return filter;
|
266 | })
|
267 | |
268 |
|
269 |
|
270 | .filter(() => this._getNumberOfRunningInstances() < config.maxInstances)
|
271 | |
272 |
|
273 |
|
274 | .filter((a) => a.availableInstances > 0)
|
275 | |
276 |
|
277 |
|
278 | .filter((a) => a.specs.length > 0)
|
279 | |
280 |
|
281 |
|
282 | .sort((a, b) => a.runningInstances - b.runningInstances);
|
283 | |
284 |
|
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 |
|
298 |
|
299 |
|
300 | _getNumberOfRunningInstances() {
|
301 | return this._schedule.map((a) => a.runningInstances).reduce((a, b) => a + b);
|
302 | }
|
303 | |
304 |
|
305 |
|
306 |
|
307 | _getNumberOfSpecsLeft() {
|
308 | return this._schedule.map((a) => a.specs.length).reduce((a, b) => a + b);
|
309 | }
|
310 | |
311 |
|
312 |
|
313 |
|
314 |
|
315 |
|
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 |
|
323 | if (typeof config.specFileRetriesDelay === 'number' && config.specFileRetries > 0 && config.specFileRetries !== retries) {
|
324 | await sleep(config.specFileRetriesDelay * 1000);
|
325 | }
|
326 |
|
327 |
|
328 | const runnerId = rid || this._getRunnerId(cid);
|
329 | const processNumber = this._runnerStarted + 1;
|
330 |
|
331 |
|
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 |
|
352 | const capExecArgs = [...(config.execArgv || [])];
|
353 |
|
354 |
|
355 | const defaultArgs = (capExecArgs.length) ? process.execArgv : [];
|
356 |
|
357 | const execArgv = [...defaultArgs, ...debugArgs, ...capExecArgs];
|
358 |
|
359 | this._runnerStarted++;
|
360 |
|
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 |
|
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 |
|
378 |
|
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 |
|
417 |
|
418 |
|
419 |
|
420 | _getRunnerId(cid) {
|
421 | if (!this._rid[cid]) {
|
422 | this._rid[cid] = 0;
|
423 | }
|
424 | return `${cid}-${this._rid[cid]++}`;
|
425 | }
|
426 | |
427 |
|
428 |
|
429 |
|
430 |
|
431 |
|
432 |
|
433 | async _endHandler({ cid: rid, exitCode, specs, retries }) {
|
434 | const passed = this._isWatchModeHalted() || exitCode === 0;
|
435 | if (!passed && retries > 0) {
|
436 |
|
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 |
|
446 |
|
447 | if (!this._isWatchModeHalted() && this.interface) {
|
448 | this.interface.emit('job:end', { cid: rid, passed, retries });
|
449 | }
|
450 | |
451 |
|
452 |
|
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 |
|
465 |
|
466 |
|
467 |
|
468 | const shouldRunSpecs = this._runSpecs();
|
469 | const inWatchMode = this._isWatchMode && !this._hasTriggeredExitRoutine;
|
470 | if (!shouldRunSpecs || inWatchMode) {
|
471 | |
472 |
|
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 |
|
485 |
|
486 |
|
487 |
|
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 |
|
502 |
|
503 |
|
504 | _isWatchModeHalted() {
|
505 | return this._isWatchMode && this._hasTriggeredExitRoutine;
|
506 | }
|
507 | }
|
508 | export default Launcher;
|