UNPKG

20.5 kBJavaScriptView Raw
1'use strict';
2const os = require('os');
3const path = require('path');
4const stream = require('stream');
5
6const cliCursor = require('cli-cursor');
7const figures = require('figures');
8const indentString = require('indent-string');
9const ora = require('ora');
10const plur = require('plur');
11const trimOffNewlines = require('trim-off-newlines');
12const beautifyStack = require('./beautify-stack');
13
14const chalk = require('../chalk').get();
15const codeExcerpt = require('../code-excerpt');
16const colors = require('./colors');
17const formatSerializedError = require('./format-serialized-error');
18const improperUsageMessages = require('./improper-usage-messages');
19const prefixTitle = require('./prefix-title');
20const whileCorked = require('./while-corked');
21
22const nodeInternals = require('stack-utils').nodeInternals();
23
24class LineWriter extends stream.Writable {
25 constructor(dest, spinner) {
26 super();
27
28 this.dest = dest;
29 this.columns = dest.columns || 80;
30 this.spinner = spinner;
31 this.lastSpinnerText = '';
32 }
33
34 _write(chunk, encoding, callback) {
35 // Discard the current spinner output. Any lines that were meant to be
36 // preserved should be rewritten.
37 this.spinner.clear();
38
39 this._writeWithSpinner(chunk.toString('utf8'));
40 callback();
41 }
42
43 _writev(pieces, callback) {
44 // Discard the current spinner output. Any lines that were meant to be
45 // preserved should be rewritten.
46 this.spinner.clear();
47
48 const last = pieces.pop();
49 for (const piece of pieces) {
50 this.dest.write(piece.chunk);
51 }
52
53 this._writeWithSpinner(last.chunk.toString('utf8'));
54 callback();
55 }
56
57 _writeWithSpinner(string) {
58 if (!this.spinner.id) {
59 this.dest.write(string);
60 return;
61 }
62
63 this.lastSpinnerText = string;
64 // Ignore whitespace at the end of the chunk. We're continiously rewriting
65 // the last line through the spinner. Also be careful to remove the indent
66 // as the spinner adds its own.
67 this.spinner.text = string.trimEnd().slice(2);
68 this.spinner.render();
69 }
70
71 writeLine(string) {
72 if (string) {
73 this.write(indentString(string, 2) + os.EOL);
74 } else {
75 this.write(os.EOL);
76 }
77 }
78}
79
80class MiniReporter {
81 constructor(options) {
82 this.reportStream = options.reportStream;
83 this.stdStream = options.stdStream;
84 this.watching = options.watching;
85
86 this.spinner = ora({
87 isEnabled: true,
88 color: options.spinner ? options.spinner.color : 'gray',
89 discardStdin: !options.watching,
90 hideCursor: false,
91 spinner: options.spinner || (process.platform === 'win32' ? 'line' : 'dots'),
92 stream: options.reportStream
93 });
94 this.lineWriter = new LineWriter(this.reportStream, this.spinner);
95
96 this.consumeStateChange = whileCorked(this.reportStream, whileCorked(this.lineWriter, this.consumeStateChange));
97 this.endRun = whileCorked(this.reportStream, whileCorked(this.lineWriter, this.endRun));
98 this.relativeFile = file => path.relative(options.projectDir, file);
99
100 this.reset();
101 }
102
103 reset() {
104 if (this.removePreviousListener) {
105 this.removePreviousListener();
106 }
107
108 this.failFastEnabled = false;
109 this.failures = [];
110 this.filesWithMissingAvaImports = new Set();
111 this.filesWithoutDeclaredTests = new Set();
112 this.filesWithoutMatchedLineNumbers = new Set();
113 this.internalErrors = [];
114 this.knownFailures = [];
115 this.lineNumberErrors = [];
116 this.matching = false;
117 this.prefixTitle = (testFile, title) => title;
118 this.previousFailures = 0;
119 this.removePreviousListener = null;
120 this.stats = null;
121 this.uncaughtExceptions = [];
122 this.unhandledRejections = [];
123 }
124
125 startRun(plan) {
126 if (plan.bailWithoutReporting) {
127 return;
128 }
129
130 this.reset();
131
132 this.failFastEnabled = plan.failFastEnabled;
133 this.matching = plan.matching;
134 this.previousFailures = plan.previousFailures;
135
136 if (this.watching || plan.files.length > 1) {
137 this.prefixTitle = (testFile, title) => prefixTitle(plan.filePathPrefix, testFile, title);
138 }
139
140 this.removePreviousListener = plan.status.on('stateChange', evt => this.consumeStateChange(evt));
141
142 if (this.watching && plan.runVector > 1) {
143 this.reportStream.write(chalk.gray.dim('\u2500'.repeat(this.lineWriter.columns)) + os.EOL);
144 }
145
146 cliCursor.hide(this.reportStream);
147 this.lineWriter.writeLine();
148
149 this.spinner.start();
150 }
151
152 consumeStateChange(evt) { // eslint-disable-line complexity
153 const fileStats = this.stats && evt.testFile ? this.stats.byFile.get(evt.testFile) : null;
154
155 switch (evt.type) {
156 case 'declared-test':
157 // Ignore
158 break;
159 case 'hook-failed':
160 this.failures.push(evt);
161 this.writeTestSummary(evt);
162 break;
163 case 'internal-error':
164 this.internalErrors.push(evt);
165 if (evt.testFile) {
166 this.writeWithCounts(colors.error(`${figures.cross} Internal error when running ${this.relativeFile(evt.testFile)}`));
167 } else {
168 this.writeWithCounts(colors.error(`${figures.cross} Internal error`));
169 }
170
171 break;
172 case 'line-number-selection-error':
173 this.lineNumberErrors.push(evt);
174 this.writeWithCounts(colors.information(`${figures.warning} Could not parse ${this.relativeFile(evt.testFile)} for line number selection`));
175 break;
176 case 'missing-ava-import':
177 this.filesWithMissingAvaImports.add(evt.testFile);
178 this.writeWithCounts(colors.error(`${figures.cross} No tests found in ${this.relativeFile(evt.testFile)}, make sure to import "ava" at the top of your test file`));
179 break;
180 case 'selected-test':
181 // Ignore
182 break;
183 case 'stats':
184 this.stats = evt.stats;
185 break;
186 case 'test-failed':
187 this.failures.push(evt);
188 this.writeTestSummary(evt);
189 break;
190 case 'test-passed':
191 if (evt.knownFailing) {
192 this.knownFailures.push(evt);
193 }
194
195 this.writeTestSummary(evt);
196 break;
197 case 'timeout':
198 this.lineWriter.writeLine(colors.error(`\n${figures.cross} Timed out while running tests`));
199 this.lineWriter.writeLine('');
200 this.writePendingTests(evt);
201 break;
202 case 'interrupt':
203 this.lineWriter.writeLine(colors.error(`\n${figures.cross} Exiting due to SIGINT`));
204 this.lineWriter.writeLine('');
205 this.writePendingTests(evt);
206 break;
207 case 'uncaught-exception':
208 this.uncaughtExceptions.push(evt);
209 break;
210 case 'unhandled-rejection':
211 this.unhandledRejections.push(evt);
212 break;
213 case 'worker-failed':
214 if (fileStats.declaredTests === 0) {
215 this.filesWithoutDeclaredTests.add(evt.testFile);
216 }
217
218 break;
219 case 'worker-finished':
220 if (fileStats.declaredTests === 0) {
221 this.filesWithoutDeclaredTests.add(evt.testFile);
222 this.writeWithCounts(colors.error(`${figures.cross} No tests found in ${this.relativeFile(evt.testFile)}`));
223 } else if (fileStats.selectingLines && fileStats.selectedTests === 0) {
224 this.filesWithoutMatchedLineNumbers.add(evt.testFile);
225 this.writeWithCounts(colors.error(`${figures.cross} Line numbers for ${this.relativeFile(evt.testFile)} did not match any tests`));
226 }
227
228 break;
229 case 'worker-stderr':
230 case 'worker-stdout':
231 // Forcibly clear the spinner, writing the chunk corrupts the TTY.
232 this.spinner.clear();
233
234 this.stdStream.write(evt.chunk);
235 // If the chunk does not end with a linebreak, *forcibly* write one to
236 // ensure it remains visible in the TTY.
237 // Tests cannot assume their standard output is not interrupted. Indeed
238 // we multiplex stdout and stderr into a single stream. However as
239 // long as stdStream is different from reportStream users can read
240 // their original output by redirecting the streams.
241 if (evt.chunk[evt.chunk.length - 1] !== 0x0A) {
242 // Use write() rather than writeLine() so the (presumably corked)
243 // line writer will actually write the empty line before re-rendering
244 // the last spinner text below.
245 this.lineWriter.write(os.EOL);
246 }
247
248 this.lineWriter.write(this.lineWriter.lastSpinnerText);
249 break;
250 default:
251 break;
252 }
253 }
254
255 writeWithCounts(string) {
256 if (!this.stats) {
257 return this.lineWriter.writeLine(string);
258 }
259
260 string = string || '';
261 if (string !== '') {
262 string += os.EOL;
263 }
264
265 let firstLinePostfix = this.watching ?
266 ' ' + chalk.gray.dim('[' + new Date().toLocaleTimeString('en-US', {hour12: false}) + ']') :
267 '';
268
269 if (this.stats.passedTests > 0) {
270 string += os.EOL + colors.pass(`${this.stats.passedTests} passed`) + firstLinePostfix;
271 firstLinePostfix = '';
272 }
273
274 if (this.stats.passedKnownFailingTests > 0) {
275 string += os.EOL + colors.error(`${this.stats.passedKnownFailingTests} ${plur('known failure', this.stats.passedKnownFailingTests)}`);
276 }
277
278 if (this.stats.failedHooks > 0) {
279 string += os.EOL + colors.error(`${this.stats.failedHooks} ${plur('hook', this.stats.failedHooks)} failed`) + firstLinePostfix;
280 firstLinePostfix = '';
281 }
282
283 if (this.stats.failedTests > 0) {
284 string += os.EOL + colors.error(`${this.stats.failedTests} ${plur('test', this.stats.failedTests)} failed`) + firstLinePostfix;
285 firstLinePostfix = '';
286 }
287
288 if (this.stats.skippedTests > 0) {
289 string += os.EOL + colors.skip(`${this.stats.skippedTests} skipped`);
290 }
291
292 if (this.stats.todoTests > 0) {
293 string += os.EOL + colors.todo(`${this.stats.todoTests} todo`);
294 }
295
296 this.lineWriter.writeLine(string);
297 }
298
299 writeErr(evt) {
300 if (evt.err.name === 'TSError' && evt.err.object && evt.err.object.diagnosticText) {
301 this.lineWriter.writeLine(colors.errorStack(trimOffNewlines(evt.err.object.diagnosticText)));
302 return;
303 }
304
305 if (evt.err.source) {
306 this.lineWriter.writeLine(colors.errorSource(`${this.relativeFile(evt.err.source.file)}:${evt.err.source.line}`));
307 const excerpt = codeExcerpt(evt.err.source, {maxWidth: this.lineWriter.columns - 2});
308 if (excerpt) {
309 this.lineWriter.writeLine();
310 this.lineWriter.writeLine(excerpt);
311 }
312 }
313
314 if (evt.err.avaAssertionError) {
315 const result = formatSerializedError(evt.err);
316 if (result.printMessage) {
317 this.lineWriter.writeLine();
318 this.lineWriter.writeLine(evt.err.message);
319 }
320
321 if (result.formatted) {
322 this.lineWriter.writeLine();
323 this.lineWriter.writeLine(result.formatted);
324 }
325
326 const message = improperUsageMessages.forError(evt.err);
327 if (message) {
328 this.lineWriter.writeLine();
329 this.lineWriter.writeLine(message);
330 }
331 } else if (evt.err.nonErrorObject) {
332 this.lineWriter.writeLine(trimOffNewlines(evt.err.formatted));
333 } else {
334 this.lineWriter.writeLine();
335 this.lineWriter.writeLine(evt.err.summary);
336 }
337
338 const formatted = this.formatErrorStack(evt.err);
339 if (formatted.length > 0) {
340 this.lineWriter.writeLine();
341 this.lineWriter.writeLine(formatted.join('\n'));
342 }
343 }
344
345 formatErrorStack(error) {
346 if (!error.stack) {
347 return [];
348 }
349
350 if (error.shouldBeautifyStack) {
351 return beautifyStack(error.stack).map(line => {
352 if (nodeInternals.some(internal => internal.test(line))) {
353 return colors.errorStackInternal(`${figures.pointerSmall} ${line}`);
354 }
355
356 return colors.errorStack(`${figures.pointerSmall} ${line}`);
357 });
358 }
359
360 return [error.stack];
361 }
362
363 writeLogs(evt) {
364 if (evt.logs) {
365 for (const log of evt.logs) {
366 const logLines = indentString(colors.log(log), 4);
367 const logLinesWithLeadingFigure = logLines.replace(
368 /^ {4}/,
369 ` ${colors.information(figures.info)} `
370 );
371 this.lineWriter.writeLine(logLinesWithLeadingFigure);
372 }
373 }
374 }
375
376 writeTestSummary(evt) {
377 if (evt.type === 'hook-failed' || evt.type === 'test-failed') {
378 this.writeWithCounts(`${this.prefixTitle(evt.testFile, evt.title)}`);
379 } else if (evt.knownFailing) {
380 this.writeWithCounts(`${colors.error(this.prefixTitle(evt.testFile, evt.title))}`);
381 } else {
382 this.writeWithCounts(`${this.prefixTitle(evt.testFile, evt.title)}`);
383 }
384 }
385
386 writeFailure(evt) {
387 this.lineWriter.writeLine(`${colors.title(this.prefixTitle(evt.testFile, evt.title))}`);
388 this.writeLogs(evt);
389 this.lineWriter.writeLine();
390 this.writeErr(evt);
391 }
392
393 writePendingTests(evt) {
394 for (const [file, testsInFile] of evt.pendingTests) {
395 if (testsInFile.size === 0) {
396 continue;
397 }
398
399 this.lineWriter.writeLine(`${testsInFile.size} tests were pending in ${this.relativeFile(file)}\n`);
400 for (const title of testsInFile) {
401 this.lineWriter.writeLine(`${figures.circleDotted} ${this.prefixTitle(file, title)}`);
402 }
403
404 this.lineWriter.writeLine('');
405 }
406 }
407
408 endRun() { // eslint-disable-line complexity
409 this.spinner.stop();
410 cliCursor.show(this.reportStream);
411
412 if (!this.stats) {
413 this.lineWriter.writeLine(colors.error(`${figures.cross} Couldn’t find any files to test`));
414 this.lineWriter.writeLine();
415 return;
416 }
417
418 if (this.matching && this.stats.selectedTests === 0) {
419 this.lineWriter.writeLine(colors.error(`${figures.cross} Couldn’t find any matching tests`));
420 this.lineWriter.writeLine();
421 return;
422 }
423
424 this.lineWriter.writeLine();
425
426 let firstLinePostfix = this.watching ?
427 ' ' + chalk.gray.dim('[' + new Date().toLocaleTimeString('en-US', {hour12: false}) + ']') :
428 '';
429
430 if (this.filesWithMissingAvaImports.size > 0) {
431 for (const testFile of this.filesWithMissingAvaImports) {
432 this.lineWriter.writeLine(colors.error(`${figures.cross} No tests found in ${this.relativeFile(testFile)}, make sure to import "ava" at the top of your test file`) + firstLinePostfix);
433 firstLinePostfix = '';
434 }
435 }
436
437 if (this.filesWithoutDeclaredTests.size > 0) {
438 for (const testFile of this.filesWithoutDeclaredTests) {
439 if (!this.filesWithMissingAvaImports.has(testFile)) {
440 this.lineWriter.writeLine(colors.error(`${figures.cross} No tests found in ${this.relativeFile(testFile)}`) + firstLinePostfix);
441 firstLinePostfix = '';
442 }
443 }
444 }
445
446 if (this.lineNumberErrors.length > 0) {
447 for (const evt of this.lineNumberErrors) {
448 this.lineWriter.writeLine(colors.information(`${figures.warning} Could not parse ${this.relativeFile(evt.testFile)} for line number selection`));
449 }
450 }
451
452 if (this.filesWithoutMatchedLineNumbers.size > 0) {
453 for (const testFile of this.filesWithoutMatchedLineNumbers) {
454 if (!this.filesWithMissingAvaImports.has(testFile) && !this.filesWithoutDeclaredTests.has(testFile)) {
455 this.lineWriter.writeLine(colors.error(`${figures.cross} Line numbers for ${this.relativeFile(testFile)} did not match any tests`) + firstLinePostfix);
456 firstLinePostfix = '';
457 }
458 }
459 }
460
461 if (this.filesWithMissingAvaImports.size > 0 || this.filesWithoutDeclaredTests.size > 0 || this.filesWithoutMatchedLineNumbers.size > 0) {
462 this.lineWriter.writeLine();
463 }
464
465 if (this.stats.failedHooks > 0) {
466 this.lineWriter.writeLine(colors.error(`${this.stats.failedHooks} ${plur('hook', this.stats.failedHooks)} failed`) + firstLinePostfix);
467 firstLinePostfix = '';
468 }
469
470 if (this.stats.failedTests > 0) {
471 this.lineWriter.writeLine(colors.error(`${this.stats.failedTests} ${plur('test', this.stats.failedTests)} failed`) + firstLinePostfix);
472 firstLinePostfix = '';
473 }
474
475 if (this.stats.failedHooks === 0 && this.stats.failedTests === 0 && this.stats.passedTests > 0) {
476 this.lineWriter.writeLine(colors.pass(`${this.stats.passedTests} ${plur('test', this.stats.passedTests)} passed`) + firstLinePostfix);
477 firstLinePostfix = '';
478 }
479
480 if (this.stats.passedKnownFailingTests > 0) {
481 this.lineWriter.writeLine(colors.error(`${this.stats.passedKnownFailingTests} ${plur('known failure', this.stats.passedKnownFailingTests)}`));
482 }
483
484 if (this.stats.skippedTests > 0) {
485 this.lineWriter.writeLine(colors.skip(`${this.stats.skippedTests} ${plur('test', this.stats.skippedTests)} skipped`));
486 }
487
488 if (this.stats.todoTests > 0) {
489 this.lineWriter.writeLine(colors.todo(`${this.stats.todoTests} ${plur('test', this.stats.todoTests)} todo`));
490 }
491
492 if (this.stats.unhandledRejections > 0) {
493 this.lineWriter.writeLine(colors.error(`${this.stats.unhandledRejections} unhandled ${plur('rejection', this.stats.unhandledRejections)}`));
494 }
495
496 if (this.stats.uncaughtExceptions > 0) {
497 this.lineWriter.writeLine(colors.error(`${this.stats.uncaughtExceptions} uncaught ${plur('exception', this.stats.uncaughtExceptions)}`));
498 }
499
500 if (this.previousFailures > 0) {
501 this.lineWriter.writeLine(colors.error(`${this.previousFailures} previous ${plur('failure', this.previousFailures)} in test files that were not rerun`));
502 }
503
504 if (this.stats.passedKnownFailingTests > 0) {
505 this.lineWriter.writeLine();
506 for (const evt of this.knownFailures) {
507 this.lineWriter.writeLine(colors.error(this.prefixTitle(evt.testFile, evt.title)));
508 }
509 }
510
511 const shouldWriteFailFastDisclaimer = this.failFastEnabled && (this.stats.remainingTests > 0 || this.stats.files > this.stats.finishedWorkers);
512
513 if (this.failures.length > 0) {
514 const writeTrailingLines = shouldWriteFailFastDisclaimer || this.internalErrors.length > 0 || this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0;
515 this.lineWriter.writeLine();
516
517 const last = this.failures[this.failures.length - 1];
518 for (const evt of this.failures) {
519 this.writeFailure(evt);
520 if (evt !== last || writeTrailingLines) {
521 this.lineWriter.writeLine();
522 this.lineWriter.writeLine();
523 this.lineWriter.writeLine();
524 }
525 }
526 }
527
528 if (this.internalErrors.length > 0) {
529 const writeLeadingLine = this.failures.length === 0;
530 const writeTrailingLines = shouldWriteFailFastDisclaimer || this.uncaughtExceptions.length > 0 || this.unhandledRejections.length > 0;
531
532 if (writeLeadingLine) {
533 this.lineWriter.writeLine();
534 }
535
536 const last = this.internalErrors[this.internalErrors.length - 1];
537 for (const evt of this.internalErrors) {
538 if (evt.testFile) {
539 this.lineWriter.writeLine(colors.error(`${figures.cross} Internal error when running ${this.relativeFile(evt.testFile)}`));
540 } else {
541 this.lineWriter.writeLine(colors.error(`${figures.cross} Internal error`));
542 }
543
544 this.lineWriter.writeLine(colors.stack(evt.err.summary));
545 this.lineWriter.writeLine(colors.errorStack(evt.err.stack));
546 if (evt !== last || writeTrailingLines) {
547 this.lineWriter.writeLine();
548 this.lineWriter.writeLine();
549 this.lineWriter.writeLine();
550 }
551 }
552 }
553
554 if (this.uncaughtExceptions.length > 0) {
555 const writeLeadingLine = this.failures.length === 0 && this.internalErrors.length === 0;
556 const writeTrailingLines = shouldWriteFailFastDisclaimer || this.unhandledRejections.length > 0;
557
558 if (writeLeadingLine) {
559 this.lineWriter.writeLine();
560 }
561
562 const last = this.uncaughtExceptions[this.uncaughtExceptions.length - 1];
563 for (const evt of this.uncaughtExceptions) {
564 this.lineWriter.writeLine(colors.title(`Uncaught exception in ${this.relativeFile(evt.testFile)}`));
565 this.lineWriter.writeLine();
566 this.writeErr(evt);
567 if (evt !== last || writeTrailingLines) {
568 this.lineWriter.writeLine();
569 this.lineWriter.writeLine();
570 this.lineWriter.writeLine();
571 }
572 }
573 }
574
575 if (this.unhandledRejections.length > 0) {
576 const writeLeadingLine = this.failures.length === 0 && this.internalErrors.length === 0 && this.uncaughtExceptions.length === 0;
577 const writeTrailingLines = shouldWriteFailFastDisclaimer;
578
579 if (writeLeadingLine) {
580 this.lineWriter.writeLine();
581 }
582
583 const last = this.unhandledRejections[this.unhandledRejections.length - 1];
584 for (const evt of this.unhandledRejections) {
585 this.lineWriter.writeLine(colors.title(`Unhandled rejection in ${this.relativeFile(evt.testFile)}`));
586 this.lineWriter.writeLine();
587 this.writeErr(evt);
588 if (evt !== last || writeTrailingLines) {
589 this.lineWriter.writeLine();
590 this.lineWriter.writeLine();
591 this.lineWriter.writeLine();
592 }
593 }
594 }
595
596 if (shouldWriteFailFastDisclaimer) {
597 let remaining = '';
598 if (this.stats.remainingTests > 0) {
599 remaining += `At least ${this.stats.remainingTests} ${plur('test was', 'tests were', this.stats.remainingTests)} skipped`;
600 if (this.stats.files > this.stats.finishedWorkers) {
601 remaining += ', as well as ';
602 }
603 }
604
605 if (this.stats.files > this.stats.finishedWorkers) {
606 const skippedFileCount = this.stats.files - this.stats.finishedWorkers;
607 remaining += `${skippedFileCount} ${plur('test file', 'test files', skippedFileCount)}`;
608 if (this.stats.remainingTests === 0) {
609 remaining += ` ${plur('was', 'were', skippedFileCount)} skipped`;
610 }
611 }
612
613 this.lineWriter.writeLine(colors.information(`\`--fail-fast\` is on. ${remaining}.`));
614 }
615
616 this.lineWriter.writeLine();
617 }
618}
619module.exports = MiniReporter;