1 | import { EventEmitter } from 'node:events';
|
2 | import chalk, { supportsColor } from 'chalk';
|
3 | import logger from '@wdio/logger';
|
4 | import { SnapshotManager } from '@vitest/snapshot/manager';
|
5 | import { HookError } from './utils.js';
|
6 | import { getRunnerName } from './utils.js';
|
7 | const log = logger('@wdio/cli');
|
8 | const EVENT_FILTER = ['sessionStarted', 'sessionEnded', 'finishedCommand', 'ready', 'workerResponse', 'workerEvent'];
|
9 | export default class WDIOCLInterface extends EventEmitter {
|
10 | _config;
|
11 | totalWorkerCnt;
|
12 | _isWatchMode;
|
13 | #snapshotManager = new SnapshotManager({
|
14 | updateSnapshot: 'new'
|
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 |
|
40 |
|
41 |
|
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 |
|
61 |
|
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 |
|
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 |
|
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 |
|
169 |
|
170 | log(...args) {
|
171 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 | }
|