From 00e596b68eb83bf34237b2933fb33ddbbbdf6b8c Mon Sep 17 00:00:00 2001 From: Dmitrii Abramov Date: Wed, 17 Aug 2016 20:08:11 -0700 Subject: [PATCH] Concurrent reporter --- .../__snapshots__/console-test.js.snap | 6 +- .../__snapshots__/snapshot-test.js.snap | 79 +++++++ .../__snapshots__/stack_trace-test.js.snap | 47 ++++ .../__tests__/json_reporter-test.js | 2 +- integration_tests/__tests__/snapshot-test.js | 29 +-- .../__tests__/stack_trace-test.js | 18 +- integration_tests/utils.js | 14 ++ packages/jest-cli/package.json | 2 + packages/jest-cli/src/TestRunner.js | 109 ++++++--- packages/jest-cli/src/cli/index.js | 4 +- packages/jest-cli/src/jest.js | 2 +- .../jest-cli/src/reporters/BaseReporter.js | 16 +- .../src/reporters/CoverageReporter.js | 2 +- .../jest-cli/src/reporters/DefaultReporter.js | 136 ++++++++--- packages/jest-cli/src/reporters/Status.js | 211 ++++++++++++++++++ .../jest-cli/src/reporters/SummaryReporter.js | 48 +--- .../jest-cli/src/reporters/VerboseReporter.js | 7 +- .../src/reporters/__tests__/Status-test.js | 73 ++++++ .../__snapshots__/Status-test.js.snap | 9 + .../__snapshots__/utils-test.js.snap | 31 +++ .../src/reporters/__tests__/utils-test.js | 25 +++ .../jest-cli/src/reporters/getResultHeader.js | 17 +- packages/jest-cli/src/reporters/utils.js | 169 ++++++++++++++ 23 files changed, 899 insertions(+), 157 deletions(-) create mode 100644 integration_tests/__tests__/__snapshots__/snapshot-test.js.snap create mode 100644 integration_tests/__tests__/__snapshots__/stack_trace-test.js.snap create mode 100644 packages/jest-cli/src/reporters/Status.js create mode 100644 packages/jest-cli/src/reporters/__tests__/Status-test.js create mode 100644 packages/jest-cli/src/reporters/__tests__/__snapshots__/Status-test.js.snap create mode 100644 packages/jest-cli/src/reporters/__tests__/__snapshots__/utils-test.js.snap create mode 100644 packages/jest-cli/src/reporters/__tests__/utils-test.js create mode 100644 packages/jest-cli/src/reporters/utils.js diff --git a/integration_tests/__tests__/__snapshots__/console-test.js.snap b/integration_tests/__tests__/__snapshots__/console-test.js.snap index 8a85d124f4f9..38d822c60057 100644 --- a/integration_tests/__tests__/__snapshots__/console-test.js.snap +++ b/integration_tests/__tests__/__snapshots__/console-test.js.snap @@ -16,7 +16,11 @@ exports[`test console printing 1`] = ` console.error __tests__/console2-test.js:18 This is an error from another test file. -" + + +suites: 2 passed (2 total) +tests: 2 passed (2 total) +snapshots: 0 passed (0 total)" `; exports[`test console printing with --verbose 1`] = ` diff --git a/integration_tests/__tests__/__snapshots__/snapshot-test.js.snap b/integration_tests/__tests__/__snapshots__/snapshot-test.js.snap new file mode 100644 index 000000000000..664c8b7ab5f8 --- /dev/null +++ b/integration_tests/__tests__/__snapshots__/snapshot-test.js.snap @@ -0,0 +1,79 @@ +exports[`Snapshot Validation deletes a snapshot when a test does removes all the snapshots 1`] = ` +"suites: 3 passed (3 total) +tests: 7 passed (7 total) +snapshots: 7 added, 0 passed (7 total) +time: <> +" +`; + +exports[`Snapshot Validation deletes a snapshot when a test does removes all the snapshots 2`] = ` +"suites: 3 passed (3 total) +tests: 5 passed (5 total) +snapshots: 4 passed (4 total) +time: <> +" +`; + +exports[`Snapshot Validation deletes the snapshot if the test file has been removed 1`] = ` +"suites: 3 passed (3 total) +tests: 7 passed (7 total) +snapshots: 7 added, 0 passed (7 total) +time: <> +" +`; + +exports[`Snapshot Validation deletes the snapshot if the test file has been removed 2`] = ` +"suites: 2 passed (2 total) +tests: 4 passed (4 total) +snapshots: 4 passed (4 total) +time: <> +" +`; + +exports[`Snapshot Validation updates the snapshot when a test removes some snapshots 1`] = ` +"suites: 3 passed (3 total) +tests: 7 passed (7 total) +snapshots: 7 added, 0 passed (7 total) +time: <> +" +`; + +exports[`Snapshot Validation updates the snapshot when a test removes some snapshots 2`] = ` +"suites: 3 passed (3 total) +tests: 7 passed (7 total) +snapshots: 1 updated, 5 passed (6 total) +time: <> +" +`; + +exports[`Snapshot works as expected 1`] = ` +"suites: 2 passed (2 total) +tests: 4 passed (4 total) +snapshots: 4 added, 0 passed (4 total) +time: <> +" +`; + +exports[`Snapshot works with escaped characters 1`] = ` +"suites: 1 passed (1 total) +tests: 1 passed (1 total) +snapshots: 1 added, 0 passed (1 total) +time: <> +" +`; + +exports[`Snapshot works with escaped characters 2`] = ` +"suites: 1 passed (1 total) +tests: 2 passed (2 total) +snapshots: 1 added, 1 passed (2 total) +time: <> +" +`; + +exports[`Snapshot works with escaped characters 3`] = ` +"suites: 1 passed (1 total) +tests: 2 passed (2 total) +snapshots: 2 passed (2 total) +time: <> +" +`; diff --git a/integration_tests/__tests__/__snapshots__/stack_trace-test.js.snap b/integration_tests/__tests__/__snapshots__/stack_trace-test.js.snap new file mode 100644 index 000000000000..d32b0dd63e42 --- /dev/null +++ b/integration_tests/__tests__/__snapshots__/stack_trace-test.js.snap @@ -0,0 +1,47 @@ +exports[`Stack Trace does not print a stack trace for errors when --noStackTrace is given 1`] = ` +"suites: 1 failed, 1 passed (1 total) +tests: 3 failed, 0 passed (3 total) +snapshots: 0 passed (0 total) +time: <> +" +`; + +exports[`Stack Trace does not print a stack trace for matching errors when --noStackTrace is given 1`] = ` +"suites: 1 failed, 1 passed (1 total) +tests: 1 failed, 0 passed (1 total) +snapshots: 0 passed (0 total) +time: <> +" +`; + +exports[`Stack Trace does not print a stack trace for runtime errors when --noStackTrace is given 1`] = ` +"suites: 1 failed, 0 passed (1 total) +tests: 0 passed (0 total) +snapshots: 0 passed (0 total) +time: <> +" +`; + +exports[`Stack Trace prints a stack trace for errors 1`] = ` +"suites: 1 failed, 1 passed (1 total) +tests: 3 failed, 0 passed (3 total) +snapshots: 0 passed (0 total) +time: <> +" +`; + +exports[`Stack Trace prints a stack trace for matching errors 1`] = ` +"suites: 1 failed, 1 passed (1 total) +tests: 1 failed, 0 passed (1 total) +snapshots: 0 passed (0 total) +time: <> +" +`; + +exports[`Stack Trace prints a stack trace for runtime errors 1`] = ` +"suites: 1 failed, 0 passed (1 total) +tests: 0 passed (0 total) +snapshots: 0 passed (0 total) +time: <> +" +`; diff --git a/integration_tests/__tests__/json_reporter-test.js b/integration_tests/__tests__/json_reporter-test.js index 4f8ab1c887b9..6134dc2fa3d4 100644 --- a/integration_tests/__tests__/json_reporter-test.js +++ b/integration_tests/__tests__/json_reporter-test.js @@ -16,7 +16,7 @@ describe('JSON Reporter', () => { const stderr = result.stderr.toString(); let jsonResult; - expect(stderr).toMatch(/1 test failed, 1 test passed/); + expect(stderr).toMatch(/1 failed, 1 passed/); expect(result.status).toBe(1); try { diff --git a/integration_tests/__tests__/snapshot-test.js b/integration_tests/__tests__/snapshot-test.js index 3a1bd981de95..6f10e256cb53 100644 --- a/integration_tests/__tests__/snapshot-test.js +++ b/integration_tests/__tests__/snapshot-test.js @@ -9,6 +9,7 @@ */ 'use strict'; +const {getSummary} = require('../utils'); const fs = require('fs'); const path = require('path'); const runJest = require('../runJest'); @@ -97,8 +98,7 @@ describe('Snapshot', () => { const info = result.stderr.toString(); expect(info).toMatch('4 snapshots written in 2 test files'); - expect(info).toMatch('4 tests passed'); - expect(info).toMatch('4 total in 2 test suites, 4 snapshots'); + expect(getSummary(info)).toMatchSnapshot(); }); it('works with escaped characters', () => { @@ -107,8 +107,8 @@ describe('Snapshot', () => { let stderr = result.stderr.toString(); expect(stderr).toMatch('1 snapshot written'); - expect(stderr).toMatch('1 total in 1 test suite, 1 snapshot,'); expect(result.status).toBe(0); + expect(getSummary(stderr)).toMatchSnapshot(); // Write the second snapshot const testData = @@ -121,7 +121,7 @@ describe('Snapshot', () => { stderr = result.stderr.toString(); expect(stderr).toMatch('1 snapshot written'); - expect(stderr).toMatch('2 total in 1 test suite, 2 snapshots,'); + expect(getSummary(stderr)).toMatchSnapshot(); expect(result.status).toBe(0); // Now let's check again if everything still passes. @@ -130,7 +130,7 @@ describe('Snapshot', () => { stderr = result.stderr.toString(); expect(stderr).not.toMatch('Snapshot Summary'); - expect(stderr).toMatch('2 total in 1 test suite, 2 snapshots,'); + expect(getSummary(stderr)).toMatchSnapshot(); expect(result.status).toBe(0); }); @@ -155,11 +155,9 @@ describe('Snapshot', () => { const infoFR = firstRun.stderr.toString(); const infoSR = secondRun.stderr.toString(); expect(infoFR).toMatch('7 snapshots written in 3 test files'); - expect(infoFR).toMatch('7 tests passed'); - expect(infoFR).toMatch('7 total in 3 test suites'); expect(infoSR).toMatch('1 snapshot file removed'); - expect(infoSR).toMatch('4 tests passed'); - expect(infoSR).toMatch('4 total in 2 test suites'); + expect(getSummary(infoFR)).toMatchSnapshot(); + expect(getSummary(infoSR)).toMatchSnapshot(); }); it('deletes a snapshot when a test does removes all the snapshots', () => { @@ -176,11 +174,10 @@ describe('Snapshot', () => { const infoFR = firstRun.stderr.toString(); const infoSR = secondRun.stderr.toString(); expect(infoFR).toMatch('7 snapshots written in 3 test files'); - expect(infoFR).toMatch('7 tests passed'); - expect(infoFR).toMatch('7 total in 3 test suites'); expect(infoSR).toMatch('1 snapshot file removed'); - expect(infoSR).toMatch('5 tests passed'); - expect(infoSR).toMatch('5 total in 3 test suites, 4 snapshots'); + expect(getSummary(infoFR)).toMatchSnapshot(); + expect(getSummary(infoSR)).toMatchSnapshot(); + }); it('updates the snapshot when a test removes some snapshots', () => { @@ -218,12 +215,10 @@ describe('Snapshot', () => { const infoFR = firstRun.stderr.toString(); const infoSR = secondRun.stderr.toString(); expect(infoFR).toMatch('7 snapshots written in 3 test files'); - expect(infoFR).toMatch('7 tests passed'); - expect(infoFR).toMatch('7 total in 3 test suites, 7 snapshots'); + expect(getSummary(infoFR)).toMatchSnapshot(); expect(infoSR).toMatch('1 snapshot updated in 1 test file'); expect(infoSR).toMatch('1 obsolete snapshot removed'); - expect(infoSR).toMatch('7 tests passed'); - expect(infoSR).toMatch('7 total in 3 test suites, 6 snapshots'); + expect(getSummary(infoSR)).toMatchSnapshot(); }); }); diff --git a/integration_tests/__tests__/stack_trace-test.js b/integration_tests/__tests__/stack_trace-test.js index 284ee8e0d5a0..d4fe73071d9c 100644 --- a/integration_tests/__tests__/stack_trace-test.js +++ b/integration_tests/__tests__/stack_trace-test.js @@ -8,6 +8,7 @@ 'use strict'; const runJest = require('../runJest'); +const {getSummary} = require('../utils'); describe('Stack Trace', () => { @@ -15,9 +16,8 @@ describe('Stack Trace', () => { const result = runJest('stack_trace', ['runtime-error-test.js']); const stderr = result.stderr.toString(); - expect(stderr).toMatch( - /1 test suite failed, 0 tests passed/ - ); + expect(getSummary(stderr)).toMatchSnapshot(); + expect(result.status).toBe(1); expect(stderr).toMatch( /ReferenceError: thisIsARuntimeError is not defined/ @@ -34,9 +34,7 @@ describe('Stack Trace', () => { ]); const stderr = result.stderr.toString(); - expect(stderr).toMatch( - /1 test suite failed, 0 tests passed/ - ); + expect(getSummary(stderr)).toMatchSnapshot(); expect(result.status).toBe(1); expect(stderr).toMatch( @@ -51,7 +49,7 @@ describe('Stack Trace', () => { const result = runJest('stack_trace', ['stack-trace-test.js']); const stderr = result.stderr.toString(); - expect(stderr).toMatch(/1 test failed, 0 tests passed/); + expect(getSummary(stderr)).toMatchSnapshot(); expect(result.status).toBe(1); expect(stderr).toMatch( @@ -66,7 +64,7 @@ describe('Stack Trace', () => { ]); const stderr = result.stderr.toString(); - expect(stderr).toMatch(/1 test failed, 0 tests passed/); + expect(getSummary(stderr)).toMatchSnapshot(); expect(result.status).toBe(1); expect(stderr).not.toMatch( @@ -78,7 +76,7 @@ describe('Stack Trace', () => { const result = runJest('stack_trace', ['test-error-test.js']); const stderr = result.stderr.toString(); - expect(stderr).toMatch(/3 tests failed, 0 tests passed/); + expect(getSummary(stderr)).toMatchSnapshot(); expect(result.status).toBe(1); expect(stderr).toMatch(/this is unexpected\./); @@ -107,7 +105,7 @@ describe('Stack Trace', () => { ]); const stderr = result.stderr.toString(); - expect(stderr).toMatch(/3 tests failed, 0 tests passed/); + expect(getSummary(stderr)).toMatchSnapshot(); expect(result.status).toBe(1); expect(stderr).not.toMatch( diff --git a/integration_tests/utils.js b/integration_tests/utils.js index ae636a8ccf8b..3fcb3cbb9844 100644 --- a/integration_tests/utils.js +++ b/integration_tests/utils.js @@ -46,7 +46,21 @@ const fileExists = filePath => { } }; +const getSummary = stdout => { + const match = stdout.match(/suites:.*\ntests.*\nsnapshots.*\ntime.*\n*$/g); + if (!match) { + throw new Error(` + Could not find test summary in the output. + OUTPUT: + ${stdout} + `); + } + + return match[0].replace(/\d*\.\d*s/, '<>'); +}; + module.exports = { + getSummary, fileExists, linkJestPackage, run, diff --git a/packages/jest-cli/package.json b/packages/jest-cli/package.json index ca48ed5e2aee..71629b381a97 100644 --- a/packages/jest-cli/package.json +++ b/packages/jest-cli/package.json @@ -24,6 +24,8 @@ "json-stable-stringify": "^1.0.0", "node-notifier": "^4.6.0", "sane": "^1.2.0", + "strip-ansi": "^3.0.1", + "throat": "^3.0.0", "which": "^1.1.1", "worker-farm": "^1.3.1", "yargs": "^5.0.0" diff --git a/packages/jest-cli/src/TestRunner.js b/packages/jest-cli/src/TestRunner.js index be2777e9883b..6f3bd499c900 100644 --- a/packages/jest-cli/src/TestRunner.js +++ b/packages/jest-cli/src/TestRunner.js @@ -28,6 +28,7 @@ const VerboseReporter = require('./reporters/VerboseReporter'); const promisify = require('./lib/promisify'); const runTest = require('./runTest'); const snapshot = require('jest-snapshot'); +const throat = require('throat'); const workerFarm = require('worker-farm'); const SLOW_TEST_TIME = 3000; @@ -151,16 +152,23 @@ class TestRunner { return; } addResult(aggregatedResults, testResult); - this._dispatcher.onTestResult(config, testResult, aggregatedResults); + this._dispatcher.onTestResult( + config, + testResult, + aggregatedResults, + ); this._bailIfNeeded(aggregatedResults); }; const onRunFailure = (testPath: Path, err: TestError) => { const testResult = buildFailureTestResult(testPath, err); testResult.failureMessage = formatExecError(testResult, config, testPath); - aggregatedResults.testResults.push(testResult); - aggregatedResults.numRuntimeErrorTestSuites++; - this._dispatcher.onTestResult(config, testResult, aggregatedResults); + addResult(aggregatedResults, testResult); + this._dispatcher.onTestResult( + config, + testResult, + aggregatedResults, + ); }; const setSuccess = () => { @@ -222,12 +230,19 @@ class TestRunner { onTestResult: OnTestResult, onRunFailure: OnRunFailure, ) { - return testPaths.reduce((promise, path) => - promise - .then(() => this._hasteContext) - .then(data => runTest(path, this._config, data.resolver)) - .then(result => onTestResult(path, result)) - .catch(err => onRunFailure(path, err)), + const mutex = throat(1); + + return testPaths.reduce( + (promise, path) => { + return mutex(() => { + return promise + .then(() => this._dispatcher.onTestStart(this._config, path)) + .then(() => this._hasteContext) + .then(data => runTest(path, this._config, data.resolver)) + .then(result => onTestResult(path, result)) + .catch(err => onRunFailure(path, err)); + }); + }, Promise.resolve(), ); } @@ -244,25 +259,42 @@ class TestRunner { maxRetries: 2, // Allow for a couple of transient errors. maxConcurrentWorkers: this._options.maxWorkers, }, TEST_WORKER_PATH); - const runTestInWorkerFarm = promisify(farm); - return Promise.all(testPaths.map( - path => runTestInWorkerFarm({path, config}) + const mutex = throat(this._options.maxWorkers); + const runTestInFarm = ({path, config}) => { + // send test suites to workes continuously, not just dump the whole + // map at once. We need this to track the start time of individual tests. + return mutex(() => { + this._dispatcher.onTestStart(config, path); + return promisify(farm)({path, config}); + }); + }; + + const catchError = (err, path) => { + onRunFailure(path, err); + if (err.type === 'ProcessTerminatedError') { + console.error( + 'A worker process has quit unexpectedly! ' + + 'Most likely this an initialization error.', + ); + process.exit(1); + } + }; + + return Promise.all(testPaths.map(path => { + return runTestInFarm({path, config}) .then(testResult => onTestResult(path, testResult)) - .catch(err => { - onRunFailure(path, err); - if (err.type === 'ProcessTerminatedError') { - console.error( - 'A worker process has quit unexpectedly! ' + - 'Most likely this an initialization error.', - ); - process.exit(1); - } - })), - ) + .catch(err => catchError(err, path)); + })) .then(() => workerFarm.end(farm)); } _setupReporters() { + this.addReporter( + this._config.verbose + ? new VerboseReporter() + : new DefaultReporter(), + ); + if (this._config.collectCoverage) { // coverage reporter dependency graph is pretty big and we don't // want to require it if we're not in the `--coverage` mode @@ -270,12 +302,6 @@ class TestRunner { this.addReporter(new CoverageReporter()); } this.addReporter(new SummaryReporter()); - this.addReporter( - this._config.verbose - ? new VerboseReporter() - : new DefaultReporter(), - ); - if (this._config.notify) { this.addReporter(new NotifyReporter()); } @@ -324,7 +350,7 @@ const createAggregatedResults = (numTotalTestSuites: number) => { const addResult = ( aggregatedResults: AggregatedResult, testResult: TestResult, -) => { +): void => { aggregatedResults.testResults.push(testResult); aggregatedResults.numTotalTests += testResult.numPassingTests + @@ -334,7 +360,10 @@ const addResult = ( aggregatedResults.numFailedTests += testResult.numFailingTests; aggregatedResults.numPassedTests += testResult.numPassingTests; aggregatedResults.numPendingTests += testResult.numPendingTests; - if (testResult.numFailingTests > 0) { + if (testResult.testExecError) { + aggregatedResults.numRuntimeErrorTestSuites++; + } + if (testResult.numFailingTests > 0 || testResult.testExecError) { aggregatedResults.numFailedTestSuites++; } else { aggregatedResults.numPassedTestSuites++; @@ -366,6 +395,7 @@ const addResult = ( aggregatedResults.snapshot.total += testResult.snapshot.added + testResult.snapshot.matched + + testResult.snapshot.unmatched + testResult.snapshot.updated; }; @@ -376,7 +406,7 @@ const buildFailureTestResult = ( return { failureMessage: null, hasUncheckedKeys: false, - numFailingTests: 1, + numFailingTests: 0, numPassingTests: 0, numPendingTests: 0, perfStats: { @@ -422,7 +452,18 @@ class ReporterDispatcher { onTestResult(config, testResult, results) { this._reporters.forEach(reporter => - reporter.onTestResult(config, testResult, results, this._runnerContext), + reporter.onTestResult( + config, + testResult, + results, + this._runnerContext, + ), + ); + } + + onTestStart(config, path) { + this._reporters.forEach(reporter => + reporter.onTestStart(config, path, this._runnerContext), ); } diff --git a/packages/jest-cli/src/cli/index.js b/packages/jest-cli/src/cli/index.js index 0f8ff6692126..cbdbba163e5e 100644 --- a/packages/jest-cli/src/cli/index.js +++ b/packages/jest-cli/src/cli/index.js @@ -48,7 +48,9 @@ function run(argv?: Object, root?: Path) { root = getPackageRoot(); } getJest(root).runCLI(argv, root, success => { - process.on('exit', () => process.exit(success ? 0 : 1)); + setTimeout(() => { + process.on('exit', () => process.exit(success ? 0 : 1)); + }, 500); }); } diff --git a/packages/jest-cli/src/jest.js b/packages/jest-cli/src/jest.js index 77fbccb9f3e6..53d2a3556562 100644 --- a/packages/jest-cli/src/jest.js +++ b/packages/jest-cli/src/jest.js @@ -39,7 +39,7 @@ function getMaxWorkers(argv) { if (argv.runInBand) { return 1; } else if (argv.maxWorkers) { - return argv.maxWorkers; + return parseInt(argv.maxWorkers, 10); } else { const cpus = os.cpus().length; return Math.max(argv.watch ? Math.floor(cpus / 2) : cpus - 1, 1); diff --git a/packages/jest-cli/src/reporters/BaseReporter.js b/packages/jest-cli/src/reporters/BaseReporter.js index 2d071faa0bb5..6da046c80c9e 100644 --- a/packages/jest-cli/src/reporters/BaseReporter.js +++ b/packages/jest-cli/src/reporters/BaseReporter.js @@ -10,7 +10,7 @@ 'use strict'; import type {AggregatedResult, TestResult} from 'types/TestResult'; -import type {Config} from 'types/Config'; +import type {Config, Path} from 'types/Config'; import type {RunnerContext} from 'types/Reporters'; class BaseReporter { @@ -28,6 +28,12 @@ class BaseReporter { results: AggregatedResult, ) {} + onTestStart( + config: Config, + path: Path, + runnerContext: RunnerContext, + ) {} + onRunComplete( config: Config, aggregatedResults: AggregatedResult, @@ -35,13 +41,7 @@ class BaseReporter { ): ?Promise {} _write(string: string) { - // If we write more than one character at a time it is possible that - // node exits in the middle of printing the result. - // If you are reading this and you are from the future, this might not - // be true any more. - for (let i = 0; i < string.length; i++) { - process.stderr.write(string.charAt(i)); - } + process.stderr.write(string); } _setError(error: Error) { diff --git a/packages/jest-cli/src/reporters/CoverageReporter.js b/packages/jest-cli/src/reporters/CoverageReporter.js index e93fab876ddd..9221c7fca003 100644 --- a/packages/jest-cli/src/reporters/CoverageReporter.js +++ b/packages/jest-cli/src/reporters/CoverageReporter.js @@ -10,7 +10,7 @@ 'use strict'; import type {AggregatedResult, TestResult} from 'types/TestResult'; -import type {Config} from 'types/Config'; +import type {Config, Path} from 'types/Config'; import type {RunnerContext} from 'types/Reporters'; type CoverageMap = { diff --git a/packages/jest-cli/src/reporters/DefaultReporter.js b/packages/jest-cli/src/reporters/DefaultReporter.js index 1ec80b88e33a..cbed4e773b7d 100644 --- a/packages/jest-cli/src/reporters/DefaultReporter.js +++ b/packages/jest-cli/src/reporters/DefaultReporter.js @@ -7,40 +7,136 @@ * * @flow */ + + /* global stream$Writable, tty$WriteStream */ + 'use strict'; import type {AggregatedResult, TestResult} from 'types/TestResult'; -import type {Config} from 'types/Config'; +import type {Config, Path} from 'types/Config'; const BaseReporter = require('./BaseReporter'); const chalk = require('chalk'); -const clearLine = require('jest-util').clearLine; const getConsoleOutput = require('./getConsoleOutput'); const getResultHeader = require('./getResultHeader'); -const RUNNING_TEST_COLOR = chalk.bold.gray; +const Status = require('./Status'); + const TITLE_BULLET = chalk.bold('\u25cf '); -const pluralize = (word, count) => `${count} ${word}${count === 1 ? '' : 's'}`; +type write = (chunk: string, enc?: any, cb?: () => void) => boolean; class DefaultReporter extends BaseReporter { - onRunStart(config: Config, results: AggregatedResult) { - this._printWaitingOn(results, config); + _out: write; + _err: write; + _clear: string; // ANSI clear sequence for the last printed status + _currentStatusHeight: number; + _currentlyRunning: Map; + _lastAggregatedResults: AggregatedResult; + _status: Status; + + constructor() { + super(); + this._clear = ''; + this._out = process.stdout.write.bind(process.stdout); + this._err = process.stderr.write.bind(process.stderr); + this._status = new Status(); + this._wrapStdio(process.stdout); + this._wrapStdio(process.stderr); + this._status.onChange(() => { + this._clearStatus(); + this._printStatus(); + }); + } + + _wrapStdio(stream: stream$Writable | tty$WriteStream) { + const originalWrite = stream.write; + + let buffer = []; + let timeout = null; + + const doFlush = () => { + const string = buffer.join(''); + buffer = []; + // This is to avoid conflicts between random output and status text + this._clearStatus(); + originalWrite.call(stream, string); + this._printStatus(); + }; + + const flush = () => { + // If the process blows up no errors would be printed. + // There should be a smart way to buffer stderr, but for now + // we just won't buffer it. + if (stream === process.stderr) { + doFlush(); + } else { + if (!timeout) { + timeout = setTimeout(() => { + doFlush(); + timeout = null; + }, 100); + } + } + }; + + // $FlowFixMe + stream.write = chunk => { + buffer.push(chunk); + flush(); + return true; + }; + } + + _clearStatus() { + process.stdout.isTTY && this._out(this._clear); + } + + _printStatus() { + const {content, clear} = this._status.get(); + this._clear = clear; + process.stdout.isTTY && this._out(content); + } + + onRunStart(config: Config, aggregatedResults: AggregatedResult) { + this._status.runStarted(aggregatedResults); + } + + onTestStart(config: Config, testPath: Path) { + this._status.testStarted(testPath, config); + } + + onRunComplete() { + this._status.runFinished(); + // $FlowFixMe + process.stdout.write = this._out; + // $FlowFixMe + process.stderr.write = this._err; } onTestResult( config: Config, testResult: TestResult, - results: AggregatedResult, + aggregatedResults: AggregatedResult, ) { - this._clearWaitingOn(config); - this._printTestFileSummary(config, testResult); - this._printWaitingOn(results, config); + this._status.testFinished(config, testResult, aggregatedResults); + + if ( + !process.stdout.isTTY || + testResult.failureMessage || + (testResult.console && testResult.console.length) + ) { + this._printTestFileSummary(testResult.testFilePath, config, testResult); + } } - _printTestFileSummary(config: Config, testResult: TestResult) { - this.log(getResultHeader(testResult, config)); + _printTestFileSummary( + testPath: Path, + config: Config, + testResult: TestResult, + ) { + this.log(getResultHeader(testPath, testResult, config)); const consoleBuffer = testResult.console; if (consoleBuffer && consoleBuffer.length) { @@ -54,22 +150,6 @@ class DefaultReporter extends BaseReporter { this._write(testResult.failureMessage + '\n'); } } - - _clearWaitingOn(config: Config) { - clearLine(process.stderr); - } - - _printWaitingOn(results: AggregatedResult, config: Config) { - const remaining = results.numTotalTestSuites - - results.numPassedTestSuites - - results.numFailedTestSuites - - results.numRuntimeErrorTestSuites; - if (process.stdout.isTTY && remaining > 0) { - process.stderr.write(RUNNING_TEST_COLOR( - `Running ${pluralize('test suite', remaining)}...`, - )); - } - } } module.exports = DefaultReporter; diff --git a/packages/jest-cli/src/reporters/Status.js b/packages/jest-cli/src/reporters/Status.js new file mode 100644 index 000000000000..d62b3bd7a863 --- /dev/null +++ b/packages/jest-cli/src/reporters/Status.js @@ -0,0 +1,211 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ + +'use strict'; + +import type {AggregatedResult, TestResult} from 'types/TestResult'; +import type {Config, Path} from 'types/Config'; + +const {getSummaryLine, wrapAnsiString, formatTestPath} = require('./utils'); +const chalk = require('chalk'); +const path = require('path'); + +const RUNNING = chalk.inverse.bold(' RUNNING ') + ' '; + +/** + * This class is a perf optimization for sorting the list of currently + * running tests. It tries to keep tests in the same positions without + * shifting the whole list. + */ +class CurrentlyRunning { + _array: Array<{testPath: Path, config: Config} | null>; + + constructor() { + this._array = []; + } + + add(testPath, config) { + const index = this._array.indexOf(null); + const record = {testPath, config}; + index !== -1 ? this._array[index] = record : this._array.push(record); + } + + delete(testPath) { + const record = this._array.find( + (record, i) => record && record.testPath === testPath, + ); + this._array[this._array.indexOf(record)] = null; + } + + get() { + return this._array; + } +} + +/** + * A class that generates the CLI status of currently running tests + * and also provides an ANSI escape sequence to remove status lines + * from the terminal. + */ +class Status { + _cache: ?{content: string, clear: string}; + _callback: () => void; + _currentlyRunning: CurrentlyRunning; + _done: boolean; + _emitScheduled: boolean; + _height: number; + _interval: number; + _lastAggregatedResults: AggregatedResult; + _lastUpdated: number; + + constructor() { + this._done = false; + this._emitScheduled = false; + this._cache = null; + this._currentlyRunning = new CurrentlyRunning(); + this._height = 0; + } + + onChange(callback: () => void) { + this._callback = callback; + } + + runStarted(aggregatedResults: AggregatedResult) { + this._interval = setInterval(() => this._tick(), 1000); + this._lastAggregatedResults = aggregatedResults; + this._debouncedEmit(); + } + + runFinished() { + this._done = true; + clearInterval(this._interval); + this._emit(); + } + + testStarted(testPath: Path, config: Config) { + this._currentlyRunning.add(testPath, config); + this._debouncedEmit(); + } + + testFinished( + config: Config, + testResult: TestResult, + aggregatedResults: AggregatedResult, + ) { + const {testFilePath} = testResult; + this._lastAggregatedResults = aggregatedResults; + this._currentlyRunning.delete(testFilePath); + this._debouncedEmit(); + } + + get() { + if (this._cache) { + return this._cache; + } + + if (this._done) { + return {content: '', clear: ''}; + } + // $FlowFixMe + const columns: number = process.stdout.columns; + let content = '\n'; // status can't share lines with anything else + this._currentlyRunning.get().forEach(record => { + if (record) { + const {config, testPath} = record; + content += wrapAnsiString( + RUNNING + formatTestPath(config, testPath), + columns, + ) + '\n'; + } + }); + + if (this._lastAggregatedResults) { + content += '\n' + wrapAnsiString(getSummaryLine( + this._lastAggregatedResults, + {roundTime: true, currentSuites: true, width: columns}, + ), columns); + } + + content += '\n'; + + let clear = ''; + + let height = 1; + + for (let i = 0; i < content.length; i++) { + content[i] === '\n' && height++; + } + + for (let i = 0; i < height - 1; i++) { + clear += '\r\x1B[K\r\x1B[1A'; + } + + this._cache = {content, clear}; + return this._cache; + } + + _emit() { + this._cache = null; + this._lastUpdated = Date.now(); + this._callback(); + } + + _debouncedEmit() { + if (!this._emitScheduled) { + // perf optimization to avoid two separate renders When + // one test finishes and another test starts executing. + this._emitScheduled = true; + setTimeout(() => { + this._emit(); + this._emitScheduled = false; + }, 100); + } + } + + _tick() { + this._debouncedEmit(); + } +} + +// algorith to make test path fit the terminal screen by trimming either +// dirname, or both dirname and basename +// 123/abc.js -> ..3/abc.js +// 123/abcdef.js -> ..def.js +// 123/abc.js -> .../abc.js +const trimAndFormatPath = (pad, dirname, basename, columns) => { + const maxLength = columns - pad; + + // length is ok + if ((dirname + path.sep + basename).length <= maxLength) { + return chalk.dim(dirname + path.sep) + chalk.bold(basename); + } + + // we can fit trimmed dirname and full basename + const basenameLength = basename.length; + if (basenameLength + 4 < maxLength) { + const dirnameLength = maxLength - 4 - basenameLength; + dirname = '...' + + dirname.slice(dirname.length - dirnameLength, dirname.length); + return chalk.dim(dirname + path.sep) + chalk.bold(basename); + } + + if (basenameLength + 4 === maxLength) { + return chalk.dim('...' + path.sep) + chalk.bold(basename); + } + + // can't fit dirname, but can fit trimmed basename + return chalk.bold( + '...' + + basename.slice(basename.length - maxLength - 4, basename.length), + ); +}; + +module.exports = Status; +(module.exports: any).trimAndFormatPath = trimAndFormatPath; diff --git a/packages/jest-cli/src/reporters/SummaryReporter.js b/packages/jest-cli/src/reporters/SummaryReporter.js index 45c7dbbb7095..336d1dd8a3e7 100644 --- a/packages/jest-cli/src/reporters/SummaryReporter.js +++ b/packages/jest-cli/src/reporters/SummaryReporter.js @@ -12,65 +12,33 @@ import type {AggregatedResult, SnapshotSummary} from 'types/TestResult'; import type {Config} from 'types/Config'; +const {getSummaryLine, pluralize} = require('./utils'); const chalk = require('chalk'); const getResultHeader = require('./getResultHeader'); const BaseReporter = require('./BaseReporter'); -const FAIL_COLOR = chalk.bold.red; -const PASS_COLOR = chalk.bold.green; -const PENDING_COLOR = chalk.bold.yellow; +const FAIL_COLOR = chalk.red; const SNAPSHOT_ADDED = chalk.bold.green; const SNAPSHOT_UPDATED = chalk.bold.green; const SNAPSHOT_REMOVED = chalk.bold.red; const SNAPSHOT_SUMMARY = chalk.bold; -const TEST_SUMMARY_THRESHOLD = 20; - -const pluralize = (word, count) => `${count} ${word}${count === 1 ? '' : 's'}`; +const TEST_SUMMARY_THRESHOLD = 30; class SummareReporter extends BaseReporter { onRunComplete(config: Config, aggregatedResults: AggregatedResult) { const totalTestSuites = aggregatedResults.numTotalTestSuites; - const failedTests = aggregatedResults.numFailedTests; - const passedTests = aggregatedResults.numPassedTests; - const pendingTests = aggregatedResults.numPendingTests; - const totalTests = aggregatedResults.numTotalTests; - const totalErrors = aggregatedResults.numRuntimeErrorTestSuites; - const runTime = (Date.now() - aggregatedResults.startTime) / 1000; - const snapshots = aggregatedResults.snapshot; let results = ''; - if (snapshots.failure) { - results += FAIL_COLOR('snapshot failure') + ', '; - } - - if (failedTests) { - results += - FAIL_COLOR(`${pluralize('test', failedTests)} failed`) + ', '; - } - - if (totalErrors) { - results += - FAIL_COLOR(`${pluralize('test suite', totalErrors)} failed`) + ', '; - } - - if (pendingTests) { - results += - PENDING_COLOR(`${pluralize('test', pendingTests)} skipped`) + ', '; - } - this._printSummary(aggregatedResults, config); this._printSnapshotSummary(snapshots); if (totalTestSuites) { - results += - `${PASS_COLOR(`${pluralize('test', passedTests)} passed`)} (` + - `${totalTests} total in ${pluralize('test suite', totalTestSuites)}, ` + - (snapshots.total ? pluralize('snapshot', snapshots.total) + ', ' : '') + - `run time ${runTime}s)`; - this.log(results); + results += '\n' + getSummaryLine(aggregatedResults); } + + this.log(results); } _printSnapshotSummary(snapshots: SnapshotSummary) { @@ -155,8 +123,8 @@ class SummareReporter extends BaseReporter { const {failureMessage} = testResult; if (failureMessage) { this._write( - getResultHeader(testResult, config) + '\n' + - failureMessage + '\n', + getResultHeader(testResult.testFilePath, testResult, config) + + '\n' + failureMessage + '\n', ); } }); diff --git a/packages/jest-cli/src/reporters/VerboseReporter.js b/packages/jest-cli/src/reporters/VerboseReporter.js index 887fb5ef0eb8..5a17c8dfec7a 100644 --- a/packages/jest-cli/src/reporters/VerboseReporter.js +++ b/packages/jest-cli/src/reporters/VerboseReporter.js @@ -47,14 +47,13 @@ class VerboseReporter extends DefaultReporter { onTestResult( config: Config, testResult: TestResult, - results: AggregatedResult, + aggregatedResults: AggregatedResult, ) { - this._clearWaitingOn(config); + this._status.testFinished(config, testResult, aggregatedResults); if (!testResult.testExecError) { this._logTestResults(testResult.testResults); } - this._printTestFileSummary(config, testResult); - this._printWaitingOn(results, config); + this._printTestFileSummary(testResult.testFilePath, config, testResult); } _logTestResults(testResults: Array) { diff --git a/packages/jest-cli/src/reporters/__tests__/Status-test.js b/packages/jest-cli/src/reporters/__tests__/Status-test.js new file mode 100644 index 000000000000..4a9efcc0e097 --- /dev/null +++ b/packages/jest-cli/src/reporters/__tests__/Status-test.js @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails oncall+jsinfra + */ + +'use strict'; + +const {trimAndFormatPath} = require('../Status'); +const stripAnsi = require('strip-ansi'); +const path = require('path'); + +describe('trimAndFormatPath()', () => { + it('trims dirname', () => { + const pad = 5; + const basename = '1234.js'; + const dirname = '1234567890/1234567890'; + const columns = 25; + const result = trimAndFormatPath(pad, dirname, basename, columns); + + expect(result).toMatchSnapshot(); + expect(stripAnsi(result).length).toBe(20); + }); + + it('trims dirname (longer line width)', () => { + const pad = 5; + const basename = '1234.js'; + const dirname = '1234567890/1234567890'; + const columns = 30; + const result = trimAndFormatPath(pad, dirname, basename, columns); + + expect(result).toMatchSnapshot(); + expect(stripAnsi(result).length).toBe(25); + }); + + it('trims dirname and basename', () => { + const pad = 5; + const basename = '1234.js'; + const dirname = '1234567890/1234567890'; + const columns = 15; + const result = trimAndFormatPath(pad, dirname, basename, columns); + + expect(result).toMatchSnapshot(); + expect(stripAnsi(result).length).toBe(10); + }); + + it('does not trim anything', () => { + const pad = 5; + const basename = '1234.js'; + const dirname = '1234567890/1234567890'; + const columns = 50; + const totalLength = basename.length + path.sep.length + dirname.length; + const result = trimAndFormatPath(pad, dirname, basename, columns); + + expect(result).toMatchSnapshot(); + expect(stripAnsi(result).length).toBe(totalLength); + }); + + test('split at the path.sep index', () => { + const pad = 5; + const basename = '1234.js'; + const dirname = '1234567890'; + const columns = 16; + const result = trimAndFormatPath(pad, dirname, basename, columns); + + expect(result).toMatchSnapshot(); + expect(stripAnsi(result).length).toBe(columns - pad); + }); +}); diff --git a/packages/jest-cli/src/reporters/__tests__/__snapshots__/Status-test.js.snap b/packages/jest-cli/src/reporters/__tests__/__snapshots__/Status-test.js.snap new file mode 100644 index 000000000000..f8287badae7b --- /dev/null +++ b/packages/jest-cli/src/reporters/__tests__/__snapshots__/Status-test.js.snap @@ -0,0 +1,9 @@ +exports[`trimAndFormatPath() does not trim anything 1`] = `"1234567890/1234567890/1234.js"`; + +exports[`trimAndFormatPath() split at the path.sep index 1`] = `".../1234.js"`; + +exports[`trimAndFormatPath() trims dirname (longer line width) 1`] = `"...890/1234567890/1234.js"`; + +exports[`trimAndFormatPath() trims dirname 1`] = `"...234567890/1234.js"`; + +exports[`trimAndFormatPath() trims dirname and basename 1`] = `"...1234.js"`; diff --git a/packages/jest-cli/src/reporters/__tests__/__snapshots__/utils-test.js.snap b/packages/jest-cli/src/reporters/__tests__/__snapshots__/utils-test.js.snap new file mode 100644 index 000000000000..bd27d6076922 --- /dev/null +++ b/packages/jest-cli/src/reporters/__tests__/__snapshots__/utils-test.js.snap @@ -0,0 +1,31 @@ +exports[`wrapAnsiString() wraps a long string containing ansi chars 1`] = ` +"abcde red- +bold 12344 +56bcd 123t +tttttththt +hththththt +hththththt +hththththt +hthththtet +etetetette +tetetetete +tetetestnh +snthsnthss +ot" +`; + +exports[`wrapAnsiString() wraps a long string containing ansi chars 2`] = ` +"abcde red- +bold 12344 +56bcd 123t +tttttththt +hththththt +hththththt +hththththt +hthththtet +etetetette +tetetetete +tetetestnh +snthsnthss +ot" +`; diff --git a/packages/jest-cli/src/reporters/__tests__/utils-test.js b/packages/jest-cli/src/reporters/__tests__/utils-test.js new file mode 100644 index 000000000000..c3036cf488f4 --- /dev/null +++ b/packages/jest-cli/src/reporters/__tests__/utils-test.js @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails oncall+jsinfra + */ + +'use strict'; + +const {wrapAnsiString} = require('../utils'); +const chalk = require('chalk'); +const stripAnsi = require('strip-ansi'); + +describe('wrapAnsiString()', () => { + it('wraps a long string containing ansi chars', () => { + const string = `abcde ${chalk.red.bold('red-bold')} 1234456` + + `${chalk.dim('bcd')} 123ttttttththththththththththththththththththththth` + + `tetetetetettetetetetetetetete${chalk.underline.bold('stnhsnthsnth')}ssot`; + expect(wrapAnsiString(string, 10)).toMatchSnapshot(); + expect(stripAnsi(wrapAnsiString(string, 10))).toMatchSnapshot(); + }); +}); diff --git a/packages/jest-cli/src/reporters/getResultHeader.js b/packages/jest-cli/src/reporters/getResultHeader.js index de590f0dc0d8..cb4f6934662e 100644 --- a/packages/jest-cli/src/reporters/getResultHeader.js +++ b/packages/jest-cli/src/reporters/getResultHeader.js @@ -10,10 +10,10 @@ 'use strict'; import type {TestResult} from 'types/TestResult'; -import type {Config} from 'types/Config'; +import type {Config, Path} from 'types/Config'; +const {formatTestPath} = require('./utils'); const chalk = require('chalk'); -const path = require('path'); const LONG_TEST_COLOR = chalk.reset.bold.bgRed; // Explicitly reset for these messages since they can get written out in the @@ -21,11 +21,9 @@ const LONG_TEST_COLOR = chalk.reset.bold.bgRed; const FAIL = chalk.reset.bold.bgRed(' FAIL '); const PASS = chalk.reset.bold.bgGreen(' PASS '); -module.exports = (testResult: TestResult, config: Config) => { - const pathStr = config.rootDir - ? path.relative(config.rootDir, testResult.testFilePath) - : testResult.testFilePath; - const allTestsPassed = testResult.numFailingTests === 0; +module.exports = (testPath: Path, testResult: TestResult, config: Config) => { + const allTestsPassed = testResult.numFailingTests === 0 + && !testResult.testExecError; const runTime = testResult.perfStats ? (testResult.perfStats.end - testResult.perfStats.start) / 1000 : null; @@ -40,9 +38,6 @@ module.exports = (testResult: TestResult, config: Config) => { testDetail.push(`${toMB(testResult.memoryUsage)} MB heap size`); } - const dirname = path.dirname(pathStr); - const basename = path.basename(pathStr); - const testFileStr = chalk.gray(dirname + path.sep) + chalk.bold(basename); - return `${allTestsPassed ? PASS : FAIL} ${testFileStr}` + + return `${allTestsPassed ? PASS : FAIL} ${formatTestPath(config, testPath)}` + (testDetail.length ? ` (${testDetail.join(', ')})` : ''); }; diff --git a/packages/jest-cli/src/reporters/utils.js b/packages/jest-cli/src/reporters/utils.js new file mode 100644 index 000000000000..ae7d46417d94 --- /dev/null +++ b/packages/jest-cli/src/reporters/utils.js @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ + +'use strict'; + +import type {Config, Path} from 'types/Config'; +import type {AggregatedResult} from 'types/TestResult'; + +const chalk = require('chalk'); +const path = require('path'); + +const formatTestPath = (config: Config, testPath: Path) => { + const {dirname, basename} = relativePath(config, testPath); + return chalk.gray(dirname + path.sep) + chalk.bold(basename); +}; + +const relativePath = (config: Config, testPath: Path) => { + testPath = path.relative(config.rootDir, testPath); + const dirname = path.dirname(testPath); + const basename = path.basename(testPath); + return {dirname, basename}; +}; + +const pluralize = (word: string, count: number) => + `${count} ${word}${count === 1 ? '' : 's'}`; + +type SummaryOptions = { + roundTime?: boolean, + currentSuites?: boolean, + width?: number, +}; + +const getSummaryLine = ( + aggregatedResults: AggregatedResult, + options?: SummaryOptions, +) => { + const width = (options && options.width) || Infinity; + + let runTime = (Date.now() - aggregatedResults.startTime) / 1000; + if (options && options.roundTime) { + runTime = Math.floor(runTime); + } + const snapshotResults = aggregatedResults.snapshot; + + let suites = 'suites: '; + const suitesFailed = aggregatedResults.numFailedTestSuites; + const suitesPassed = aggregatedResults.testResults.length + - aggregatedResults.numRuntimeErrorTestSuites; + const suitesRan = aggregatedResults.testResults.length; + const suitesTotal = aggregatedResults.numTotalTestSuites; + + if (suitesFailed) { + suites += chalk.red(`${suitesFailed} failed`) + ', '; + } + + suites += chalk.green(`${suitesPassed} passed`); + suitesRan === suitesTotal + ? suites += ` (${suitesTotal} total)` + : suites += ` (${suitesRan}/${suitesTotal} total)`; + + let tests = 'tests: '; + const testsPassed = aggregatedResults.numPassedTests; + const testsFailed = aggregatedResults.numFailedTests; + const testsTotal = aggregatedResults.numTotalTests; + + if (testsFailed) { + tests += chalk.red(`${testsFailed} failed`) + ', '; + } + + tests += chalk.green(`${testsPassed} passed`); + tests += ` (${testsTotal} total)`; + + let snapshots = 'snapshots: '; + const snapshotsPassed = snapshotResults.matched; + const snapshotsFailed = snapshotResults.unmatched; + const snapshotsUpdated = snapshotResults.updated; + const snapshotsAdded = snapshotResults.added; + const snapshotsTotal = snapshotResults.total; + + if (snapshotsFailed) { + snapshots += chalk.red(`${snapshotsFailed} failed`) + ', '; + } + + if (snapshotsUpdated) { + snapshots += chalk.green(`${snapshotsUpdated} updated`) + ', '; + } + + if (snapshotsAdded) { + snapshots += chalk.green(`${snapshotsAdded} added`) + ', '; + } + + snapshots += chalk.green(`${snapshotsPassed} passed`); + snapshots += ` (${snapshotsTotal} total)`; + + const time = `time: ${runTime}s`; + + return [suites, tests, snapshots, time] + .map(line => wrapAnsiString(line, width)) + .join('\n'); +}; + +// wrap a strig that contains ANSI escape sequences. ANSI escape sequences +// do not add to the string length. +const wrapAnsiString = (string: string, width: number) => { + const ANSI_REGEXP = /[\u001b\u009b]\[\d{1,2}m/g; + const tokens = []; + let lastIndex = 0; + let match; + + while (match = ANSI_REGEXP.exec(string)) { + const ansi = match[0]; + const index = match['index']; + if (index != lastIndex) { + tokens.push(['string', string.slice(lastIndex, index)]); + } + tokens.push(['ansi', ansi]); + lastIndex = index + ansi.length; + } + + if (lastIndex != string.length - 1) { + tokens.push(['string', string.slice(lastIndex, string.length)]); + } + + let lastLineLength = 0; + + return tokens.reduce( + (lines, [kind, token]) => { + if (kind === 'string') { + if (lastLineLength + token.length > width) { + + while (token.length) { + const chunk = token.slice(0, width - lastLineLength); + const remaining = token.slice(width - lastLineLength, token.length); + lines[lines.length - 1] += chunk; + lastLineLength += chunk.length; + token = remaining; + if (token.length) { + lines.push(''); + lastLineLength = 0; + } + } + } else { + lines[lines.length - 1] += token; + lastLineLength += token.length; + } + } else { + lines[lines.length - 1] += token; + } + + return lines; + }, + [''], + ).join('\n'); +}; + +module.exports = { + formatTestPath, + getSummaryLine, + pluralize, + relativePath, + wrapAnsiString, +};