UNPKG

11 kBJavaScriptView Raw
1import { EventEmitter } from 'node:events';
2import chalk, { supportsColor } from 'chalk';
3import logger from '@wdio/logger';
4import { SnapshotManager } from '@vitest/snapshot/manager';
5import { HookError } from './utils.js';
6import { getRunnerName } from './utils.js';
7const log = logger('@wdio/cli');
8const EVENT_FILTER = ['sessionStarted', 'sessionEnded', 'finishedCommand', 'ready', 'workerResponse', 'workerEvent'];
9export default class WDIOCLInterface extends EventEmitter {
10 _config;
11 totalWorkerCnt;
12 _isWatchMode;
13 #snapshotManager = new SnapshotManager({
14 updateSnapshot: 'new' // ignored in this context
15 });
16 hasAnsiSupport;
17 result = {
18 finished: 0,
19 passed: 0,
20 retries: 0,
21 failed: 0
22 };
23 _jobs = new Map();
24 _specFileRetries;
25 _specFileRetriesDelay;
26 _skippedSpecs = 0;
27 _inDebugMode = false;
28 _start = new Date();
29 _messages = {
30 reporter: {},
31 debugger: {}
32 };
33 constructor(_config, totalWorkerCnt, _isWatchMode = false) {
34 super();
35 this._config = _config;
36 this.totalWorkerCnt = totalWorkerCnt;
37 this._isWatchMode = _isWatchMode;
38 /**
39 * Colors can be forcibly enabled/disabled with env variable `FORCE_COLOR`
40 * `FORCE_COLOR=1` - forcibly enable colors
41 * `FORCE_COLOR=0` - forcibly disable colors
42 */
43 this.hasAnsiSupport = supportsColor && supportsColor.hasBasic;
44 this.totalWorkerCnt = totalWorkerCnt;
45 this._isWatchMode = _isWatchMode;
46 this._specFileRetries = _config.specFileRetries || 0;
47 this._specFileRetriesDelay = _config.specFileRetriesDelay || 0;
48 this.on('job:start', this.addJob.bind(this));
49 this.on('job:end', this.clearJob.bind(this));
50 this.setup();
51 this.onStart();
52 }
53 #hasShard() {
54 return this._config.shard && this._config.shard.total !== 1;
55 }
56 setup() {
57 this._jobs = new Map();
58 this._start = new Date();
59 /**
60 * The relationship between totalWorkerCnt and these counters are as follows:
61 * totalWorkerCnt - retries = finished = passed + failed
62 */
63 this.result = {
64 finished: 0,
65 passed: 0,
66 retries: 0,
67 failed: 0
68 };
69 this._messages = {
70 reporter: {},
71 debugger: {}
72 };
73 }
74 onStart() {
75 const shardNote = this.#hasShard()
76 ? ` (Shard ${this._config.shard.current} of ${this._config.shard.total})`
77 : '';
78 this.log(chalk.bold(`\nExecution of ${chalk.blue(this.totalWorkerCnt)} workers${shardNote} started at`), this._start.toISOString());
79 if (this._inDebugMode) {
80 this.log(chalk.bgYellow(chalk.black('DEBUG mode enabled!')));
81 }
82 if (this._isWatchMode) {
83 this.log(chalk.bgYellow(chalk.black('WATCH mode enabled!')));
84 }
85 this.log('');
86 }
87 onSpecRunning(rid) {
88 this.onJobComplete(rid, this._jobs.get(rid), 0, chalk.bold(chalk.cyan('RUNNING')));
89 }
90 onSpecRetry(rid, job, retries = 0) {
91 const delayMsg = this._specFileRetriesDelay > 0 ? ` after ${this._specFileRetriesDelay}s` : '';
92 this.onJobComplete(rid, job, retries, chalk.bold(chalk.yellow('RETRYING') + delayMsg));
93 }
94 onSpecPass(rid, job, retries = 0) {
95 this.onJobComplete(rid, job, retries, chalk.bold(chalk.green('PASSED')));
96 }
97 onSpecFailure(rid, job, retries = 0) {
98 this.onJobComplete(rid, job, retries, chalk.bold(chalk.red('FAILED')));
99 }
100 onSpecSkip(rid, job) {
101 this.onJobComplete(rid, job, 0, 'SKIPPED', log.info);
102 }
103 onJobComplete(cid, job, retries = 0, message = '', _logger = this.log) {
104 const details = [`[${cid}]`, message];
105 if (job) {
106 details.push('in', getRunnerName(job.caps), this.getFilenames(job.specs));
107 }
108 if (retries > 0) {
109 details.push(`(${retries} retries)`);
110 }
111 return _logger(...details);
112 }
113 onTestError(payload) {
114 const error = {
115 type: payload.error?.type || 'Error',
116 message: payload.error?.message || (typeof payload.error === 'string' ? payload.error : 'Unknown error.'),
117 stack: payload.error?.stack
118 };
119 return this.log(`[${payload.cid}]`, `${chalk.red(error.type)} in "${payload.fullTitle}"\n${chalk.red(error.stack || error.message)}`);
120 }
121 getFilenames(specs = []) {
122 if (specs.length > 0) {
123 return '- ' + specs.join(', ').replace(new RegExp(`${process.cwd()}`, 'g'), '');
124 }
125 return '';
126 }
127 /**
128 * add job to interface
129 */
130 addJob({ cid, caps, specs, hasTests }) {
131 this._jobs.set(cid, { caps, specs, hasTests });
132 if (hasTests) {
133 this.onSpecRunning(cid);
134 }
135 else {
136 this._skippedSpecs++;
137 }
138 }
139 /**
140 * clear job from interface
141 */
142 clearJob({ cid, passed, retries }) {
143 const job = this._jobs.get(cid);
144 this._jobs.delete(cid);
145 const retryAttempts = this._specFileRetries - retries;
146 const retry = !passed && retries > 0;
147 if (!retry) {
148 this.result.finished++;
149 }
150 if (job && job.hasTests === false) {
151 return this.onSpecSkip(cid, job);
152 }
153 if (passed) {
154 this.result.passed++;
155 this.onSpecPass(cid, job, retryAttempts);
156 }
157 else if (retry) {
158 this.totalWorkerCnt++;
159 this.result.retries++;
160 this.onSpecRetry(cid, job, retryAttempts);
161 }
162 else {
163 this.result.failed++;
164 this.onSpecFailure(cid, job, retryAttempts);
165 }
166 }
167 /**
168 * for testing purposes call console log in a static method
169 */
170 log(...args) {
171 // eslint-disable-next-line no-console
172 console.log(...args);
173 return args;
174 }
175 logHookError(error) {
176 if (error instanceof HookError) {
177 return this.log(`${chalk.red(error.name)} in "${error.origin}"\n${chalk.red(error.stack || error.message)}`);
178 }
179 return this.log(`${chalk.red(error.name)}: ${chalk.red(error.stack || error.message)}`);
180 }
181 /**
182 * event handler that is triggered when runner sends up events
183 */
184 onMessage(event) {
185 if (event.name === 'reporterRealTime') {
186 this.log(event.content);
187 return;
188 }
189 if (event.origin === 'debugger' && event.name === 'start') {
190 this.log(chalk.yellow(event.params.introMessage));
191 this._inDebugMode = true;
192 return this._inDebugMode;
193 }
194 if (event.origin === 'debugger' && event.name === 'stop') {
195 this._inDebugMode = false;
196 return this._inDebugMode;
197 }
198 if (event.name === 'testFrameworkInit') {
199 return this.emit('job:start', event.content);
200 }
201 if (event.name === 'snapshot') {
202 const snapshotResults = event.content;
203 return snapshotResults.forEach((snapshotResult) => {
204 this.#snapshotManager.add(snapshotResult);
205 });
206 }
207 if (event.name === 'error') {
208 return this.log(`[${event.cid}]`, chalk.white(chalk.bgRed(chalk.bold(' Error: '))), event.content ? (event.content.message || event.content.stack || event.content) : '');
209 }
210 if (event.origin !== 'reporter' && event.origin !== 'debugger') {
211 /**
212 * filter certain events though
213 */
214 if (EVENT_FILTER.includes(event.name)) {
215 return;
216 }
217 return this.log(event.cid, event.origin, event.name, event.content);
218 }
219 if (event.name === 'printFailureMessage') {
220 return this.onTestError(event.content);
221 }
222 if (!this._messages[event.origin][event.name]) {
223 this._messages[event.origin][event.name] = [];
224 }
225 this._messages[event.origin][event.name].push(event.content);
226 }
227 sigintTrigger() {
228 /**
229 * allow to exit repl mode via Ctrl+C
230 */
231 if (this._inDebugMode) {
232 return false;
233 }
234 const isRunning = this._jobs.size !== 0 || this._isWatchMode;
235 const shutdownMessage = isRunning
236 ? 'Ending WebDriver sessions gracefully ...\n' +
237 '(press ctrl+c again to hard kill the runner)'
238 : 'Ended WebDriver sessions gracefully after a SIGINT signal was received!';
239 return this.log('\n\n' + shutdownMessage);
240 }
241 printReporters() {
242 /**
243 * print reporter output
244 */
245 const reporter = this._messages.reporter;
246 this._messages.reporter = {};
247 for (const [reporterName, messages] of Object.entries(reporter)) {
248 this.log('\n', chalk.bold(chalk.magenta(`"${reporterName}" Reporter:`)));
249 this.log(messages.join(''));
250 }
251 }
252 printSummary() {
253 const totalJobs = this.totalWorkerCnt - this.result.retries;
254 const elapsed = (new Date(Date.now() - this._start.getTime())).toUTCString().match(/(\d\d:\d\d:\d\d)/)[0];
255 const retries = this.result.retries ? chalk.yellow(this.result.retries, 'retries') + ', ' : '';
256 const failed = this.result.failed ? chalk.red(this.result.failed, 'failed') + ', ' : '';
257 const skipped = this._skippedSpecs > 0 ? chalk.gray(this._skippedSpecs, 'skipped') + ', ' : '';
258 const percentCompleted = totalJobs ? Math.round(this.result.finished / totalJobs * 100) : 0;
259 const snapshotSummary = this.#snapshotManager.summary;
260 const snapshotNotes = [];
261 if (snapshotSummary.added > 0) {
262 snapshotNotes.push(chalk.green(`${snapshotSummary.added} snapshot(s) added.`));
263 }
264 if (snapshotSummary.updated > 0) {
265 snapshotNotes.push(chalk.yellow(`${snapshotSummary.updated} snapshot(s) updated.`));
266 }
267 if (snapshotSummary.unmatched > 0) {
268 snapshotNotes.push(chalk.red(`${snapshotSummary.unmatched} snapshot(s) unmatched.`));
269 }
270 if (snapshotSummary.unchecked > 0) {
271 snapshotNotes.push(chalk.gray(`${snapshotSummary.unchecked} snapshot(s) unchecked.`));
272 }
273 if (snapshotNotes.length > 0) {
274 this.log('\nSnapshot Summary:');
275 snapshotNotes.forEach((note) => this.log(note));
276 }
277 return this.log('\nSpec Files:\t', chalk.green(this.result.passed, 'passed') + ', ' + retries + failed + skipped + totalJobs, 'total', `(${percentCompleted}% completed)`, 'in', elapsed, this.#hasShard()
278 ? `\nShard:\t\t ${this._config.shard.current} / ${this._config.shard.total}`
279 : '', '\n');
280 }
281 finalise() {
282 this.printReporters();
283 this.printSummary();
284 }
285}