From 7f4e2d4bf87641f406c49e92279270d458b08393 Mon Sep 17 00:00:00 2001 From: Dmitrii Abramov Date: Tue, 2 May 2017 16:39:17 -0700 Subject: [PATCH] Custom reporters (#3349) * Custom Reporters this error is not letting me lint 100% safe Reporter config 3 (#2) * add custom reporters option in TestRunner * add reporters option in jest-cli config * add flowtype for reporters option * add key for reporters in validConfig * add noDefaultReporters option noDefaultReporters option let's user turn off all the reporters set by default * Lint * add unit tests for _addCustomReporters * separate default reporters in method in TestRunner * add tests for errors which are thrown * add tests for .noDefaultReporters * modify Error thrown for _addCustomReporters * remove superfluous comment from TestRunner.js * remove reporter tests from TestRunner-test.js * add new custom reporters format in TestRunner.js * update the format for adding customReporter * add descriptive validations for reporters * add reporters attibute in normalize.js * add prettier to types * Seperate out ReporterDispatcher in a file * add elaborate messages for errors * add Facebook Copyright header to ReporterDispatcher.js * typecheck and lint properly * correcting a condition in ReporterDispatcher * rename method to `_shouldAddDefaultReporters` * add integration tests for custom_reporters * add more complete integration tests for reporters * remove AggregatedResults.js * remove any methods to be validated * correct _addDefaultReporters call * remove "reporters" validations from TestRunner.js * add pretty validations for custom reporters * remove comment * add reporter validation in normalize.js * keep comments precise remove unwanted * check if reporters exist before validation * pretty custom reporters * prettier integration_tests * prettier * yarn prettier * prettier * Remove unnecessary comments from TestRunner.js * make ReporterConfig type in types/Config simpler * remove comments * correct types and change method signatures * remove bug from reporterValidationErrors.js * make custom_reporters tests more concise * fix lint error in website this error is not letting me lint 100% safe * finalize types for reporters * yarn prettier * remove .vscode folder * all integration_tests are prettier now * remove validateReporters call * remove usage of \t in reporter validation errors * change spread operator with usage of .apply * modify custom_reporters integration_tests to suit node 4 * prettier validations * prettier :heart: * pretty lint * update lock file * Custom Reporters (merge/fix) * Use jest-resolve to resolve reporters * Minor cleanups --- .../custom-reporters-test.js.snap | 84 +++++++++ .../__tests__/custom-reporters-test.js | 100 +++++++++++ .../__tests__/add-fail-test.js | 20 +++ .../custom_reporters/__tests__/add-test.js | 20 +++ integration_tests/custom_reporters/add.js | 12 ++ .../custom_reporters/package.json | 11 ++ .../reporters/IncompleteReporter.js | 27 +++ .../reporters/TestReporter.js | 84 +++++++++ packages/jest-cli/src/ReporterDispatcher.js | 81 +++++++++ packages/jest-cli/src/TestRunner.js | 160 ++++++++++-------- .../jest-cli/src/reporters/BaseReporter.js | 10 +- .../src/reporters/CoverageReporter.js | 29 ++-- .../jest-cli/src/reporters/DefaultReporter.js | 13 +- .../jest-cli/src/reporters/NotifyReporter.js | 7 +- .../jest-cli/src/reporters/SummaryReporter.js | 33 ++-- .../jest-cli/src/reporters/VerboseReporter.js | 15 +- .../__tests__/CoverageReporter-test.js | 56 +++--- packages/jest-config/src/constants.js | 1 + packages/jest-config/src/index.js | 1 + packages/jest-config/src/normalize.js | 50 +++++- .../src/reporterValidationErrors.js | 111 ++++++++++++ packages/jest-config/src/validConfig.js | 5 + types/Config.js | 4 + types/TestRunner.js | 21 ++- 24 files changed, 791 insertions(+), 164 deletions(-) create mode 100644 integration_tests/__tests__/__snapshots__/custom-reporters-test.js.snap create mode 100644 integration_tests/__tests__/custom-reporters-test.js create mode 100644 integration_tests/custom_reporters/__tests__/add-fail-test.js create mode 100644 integration_tests/custom_reporters/__tests__/add-test.js create mode 100644 integration_tests/custom_reporters/add.js create mode 100644 integration_tests/custom_reporters/package.json create mode 100644 integration_tests/custom_reporters/reporters/IncompleteReporter.js create mode 100644 integration_tests/custom_reporters/reporters/TestReporter.js create mode 100644 packages/jest-cli/src/ReporterDispatcher.js create mode 100644 packages/jest-config/src/reporterValidationErrors.js diff --git a/integration_tests/__tests__/__snapshots__/custom-reporters-test.js.snap b/integration_tests/__tests__/__snapshots__/custom-reporters-test.js.snap new file mode 100644 index 000000000000..895c0c20d57b --- /dev/null +++ b/integration_tests/__tests__/__snapshots__/custom-reporters-test.js.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Custom Reporters Integration IncompleteReporter for flexibility 1`] = ` +"onRunComplete is called +Passed Tests: 1 +Failed Tests: 0 +Total Tests: 1 +" +`; + +exports[`Custom Reporters Integration TestReporter with all tests failing 1`] = ` +Object { + "onRunComplete": Object { + "called": true, + "numFailedTests": 1, + "numPassedTests": 0, + "numTotalTests": 1, + }, + "onRunStart": Object { + "called": true, + "options": "object", + }, + "onTestResult": Object { + "called": true, + "times": 1, + }, + "onTestStart": Object { + "called": true, + "path": false, + }, + "options": Object { + "christoph": "pojer", + "dmitrii": "abramov", + "hello": "world", + }, +} +`; + +exports[`Custom Reporters Integration TestReporter with all tests passing 1`] = ` +Object { + "onRunComplete": Object { + "called": true, + "numFailedTests": 0, + "numPassedTests": 1, + "numTotalTests": 1, + }, + "onRunStart": Object { + "called": true, + "options": "object", + }, + "onTestResult": Object { + "called": true, + "times": 1, + }, + "onTestStart": Object { + "called": true, + "path": false, + }, + "options": Object { + "christoph": "pojer", + "dmitrii": "abramov", + "hello": "world", + }, +} +`; + +exports[`Custom Reporters Integration invalid format for adding reporters 1`] = ` +"● Reporter Validation Error: + +Unexpected value for Path at index 0 of reporter at index 0 + Expected: + string + Got: + number + Reporter configuration: + [ + 3243242 + ] + + Configuration Documentation: + https://facebook.github.io/jest/docs/configuration.html + +" +`; diff --git a/integration_tests/__tests__/custom-reporters-test.js b/integration_tests/__tests__/custom-reporters-test.js new file mode 100644 index 000000000000..dbff597edbf9 --- /dev/null +++ b/integration_tests/__tests__/custom-reporters-test.js @@ -0,0 +1,100 @@ +/** + * 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. + */ +'use strict'; + +const runJest = require('../runJest'); +const skipOnWindows = require('skipOnWindows'); + +describe('Custom Reporters Integration', () => { + skipOnWindows.suite(); + + test('valid string format for adding reporters', () => { + const reporterConfig = { + reporters: ['/reporters/TestReporter.js'], + }; + + const {status} = runJest('custom_reporters', [ + '--config', + JSON.stringify(reporterConfig), + 'add-test.js', + ]); + + expect(status).toBe(0); + }); + + test('valid array format for adding reporters', () => { + const reporterConfig = { + reporters: [ + ['/reporters/TestReporter.js', {'Dmitrii Abramov': 'Awesome'}], + ], + }; + + const {status} = runJest('custom_reporters', [ + '--config', + JSON.stringify(reporterConfig), + 'add-test.js', + ]); + + expect(status).toBe(0); + }); + + test('invalid format for adding reporters', () => { + const reporterConfig = { + reporters: [[3243242]], + }; + + const {status, stderr} = runJest('custom_reporters', [ + '--config', + JSON.stringify(reporterConfig), + 'add-test.js', + ]); + + expect(status).toBe(1); + expect(stderr).toMatchSnapshot(); + }); + + test('TestReporter with all tests passing', () => { + const {stdout, status, stderr} = runJest('custom_reporters', [ + 'add-test.js', + ]); + + const parsedJSON = JSON.parse(stdout); + + expect(status).toBe(0); + expect(stderr.trim()).toBe(''); + expect(parsedJSON).toMatchSnapshot(); + }); + + test('TestReporter with all tests failing', () => { + const {stdout, status, stderr} = runJest('custom_reporters', [ + 'add-fail-test.js', + ]); + + const parsedJSON = JSON.parse(stdout); + + expect(status).toBe(1); + expect(stderr.trim()).toBe(''); + expect(parsedJSON).toMatchSnapshot(); + }); + + test('IncompleteReporter for flexibility', () => { + const {stderr, stdout, status} = runJest('custom_reporters', [ + '--no-cache', + '--config', + JSON.stringify({ + reporters: ['/reporters/IncompleteReporter.js'], + }), + 'add-test.js', + ]); + + expect(status).toBe(0); + expect(stderr.trim()).toBe(''); + + expect(stdout).toMatchSnapshot(); + }); +}); diff --git a/integration_tests/custom_reporters/__tests__/add-fail-test.js b/integration_tests/custom_reporters/__tests__/add-fail-test.js new file mode 100644 index 000000000000..58fd6b801dd5 --- /dev/null +++ b/integration_tests/custom_reporters/__tests__/add-fail-test.js @@ -0,0 +1,20 @@ +/** + * 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. + * + */ + +'use strict'; + +const add = require('../add'); + +describe('CustomReporters', () => { + test('adds fail', () => { + expect(add(1, 3)).toBe(231); + expect(add(5, 7)).toBe(120); + expect(add(2, 4)).toBe(6); + }); +}); diff --git a/integration_tests/custom_reporters/__tests__/add-test.js b/integration_tests/custom_reporters/__tests__/add-test.js new file mode 100644 index 000000000000..000b2a6a09df --- /dev/null +++ b/integration_tests/custom_reporters/__tests__/add-test.js @@ -0,0 +1,20 @@ +/** + * 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. + * + */ + +'use strict'; + +const add = require('../add'); + +describe('Custom Reporters', () => { + test('adds ok', () => { + expect(add(1, 2)).toBe(3); + expect(add(3, 4)).toBe(7); + expect(add(12, 24)).toBe(36); + }); +}); diff --git a/integration_tests/custom_reporters/add.js b/integration_tests/custom_reporters/add.js new file mode 100644 index 000000000000..9e77033def6d --- /dev/null +++ b/integration_tests/custom_reporters/add.js @@ -0,0 +1,12 @@ +/** + * 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. + * + */ + +'use strict'; + +module.exports = (x, y) => x + y; diff --git a/integration_tests/custom_reporters/package.json b/integration_tests/custom_reporters/package.json new file mode 100644 index 000000000000..590f11ffe692 --- /dev/null +++ b/integration_tests/custom_reporters/package.json @@ -0,0 +1,11 @@ +{ + "jest": { + "reporters": [ + ["/reporters/TestReporter.js", { + "hello": "world", + "dmitrii": "abramov", + "christoph": "pojer" + }] + ] + } +} diff --git a/integration_tests/custom_reporters/reporters/IncompleteReporter.js b/integration_tests/custom_reporters/reporters/IncompleteReporter.js new file mode 100644 index 000000000000..d952690d10d1 --- /dev/null +++ b/integration_tests/custom_reporters/reporters/IncompleteReporter.js @@ -0,0 +1,27 @@ +/** + * 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. + */ + +'use strict'; + +/** + * IncompleteReporter + * Reporter to test for the flexibility of the interface we implemented. + * The reporters shouldn't be required to implement all the methods + * + * This only implements one method onRunComplete which should be called + */ +class IncompleteReporter { + onRunComplete(contexts, results) { + console.log('onRunComplete is called'); + console.log('Passed Tests: ' + results.numPassedTests); + console.log('Failed Tests: ' + results.numFailedTests); + console.log('Total Tests: ' + results.numTotalTests); + } +} + +module.exports = IncompleteReporter; diff --git a/integration_tests/custom_reporters/reporters/TestReporter.js b/integration_tests/custom_reporters/reporters/TestReporter.js new file mode 100644 index 000000000000..cb5400d18ab3 --- /dev/null +++ b/integration_tests/custom_reporters/reporters/TestReporter.js @@ -0,0 +1,84 @@ +/** + * 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. + */ + +'use strict'; + +/** + * TestReporter + * Reporter for testing the outputs, without any extra + * hassle. Uses a JSON like syntax for testing the reporters + * instead of outputting the text to stdout and using match functions + * to get the output. + */ +class TestReporter { + constructor(globalConfig, options) { + this._options = options; + + /** + * statsCollected property + * contains most of the statistics + * related to the object to be called, + * This here helps us in avoiding the string match + * statements nothing else + */ + this._statsCollected = { + onRunComplete: {}, + onRunStart: {}, + onTestResult: {times: 0}, + onTestStart: {}, + options, + }; + } + + /** + * clearLine + * clears the line for easier JSON parsing + */ + clearLine() { + if (process.stdout.isTTY) { + process.stderr.write('\x1b[999D\x1b[K'); + } + } + + onTestStart(path) { + const onTestStart = this._statsCollected.onTestStart; + + onTestStart.called = true; + onTestStart.path = typeof path === 'string'; + } + + onTestResult(test, testResult, results) { + const onTestResult = this._statsCollected.onTestResult; + + onTestResult.called = true; + onTestResult.times++; + } + + onRunStart(results, options) { + this.clearLine(); + const onRunStart = this._statsCollected.onRunStart; + + onRunStart.called = true; + onRunStart.options = typeof options; + } + + onRunComplete(contexts, results) { + const onRunComplete = this._statsCollected.onRunComplete; + + onRunComplete.called = true; + + onRunComplete.numPassedTests = results.numPassedTests; + onRunComplete.numFailedTests = results.numFailedTests; + onRunComplete.numTotalTests = results.numTotalTests; + + // The Final Call + process.stdout.write(JSON.stringify(this._statsCollected, null, 4)); + } +} + +module.exports = TestReporter; diff --git a/packages/jest-cli/src/ReporterDispatcher.js b/packages/jest-cli/src/ReporterDispatcher.js new file mode 100644 index 000000000000..9d3510fb4da8 --- /dev/null +++ b/packages/jest-cli/src/ReporterDispatcher.js @@ -0,0 +1,81 @@ +/** + * 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 {Context} from 'types/Context'; +import type {Reporter, Test} from 'types/TestRunner'; +import type {TestResult, AggregatedResult} from 'types/TestResult'; +import type {ReporterOnStartOptions} from 'types/Reporters'; + +export type RunOptions = {| + estimatedTime: number, + showStatus: boolean, +|}; + +class ReporterDispatcher { + _disabled: boolean; + _reporters: Array; + + constructor() { + this._reporters = []; + } + + register(reporter: Reporter): void { + this._reporters.push(reporter); + } + + unregister(ReporterClass: Function) { + this._reporters = this._reporters.filter( + reporter => !(reporter instanceof ReporterClass), + ); + } + + onTestResult(test: Test, testResult: TestResult, results: AggregatedResult) { + this._reporters.forEach( + reporter => + reporter.onTestResult && + reporter.onTestResult(test, testResult, results), + ); + } + + onTestStart(test: Test) { + this._reporters.forEach( + reporter => reporter.onTestStart && reporter.onTestStart(test), + ); + } + + onRunStart(results: AggregatedResult, options: ReporterOnStartOptions) { + this._reporters.forEach( + reporter => reporter.onRunStart && reporter.onRunStart(results, options), + ); + } + + async onRunComplete(contexts: Set, results: AggregatedResult) { + this._reporters.forEach( + reporter => + reporter.onRunComplete && reporter.onRunComplete(contexts, results), + ); + } + + // Return a list of last errors for every reporter + getErrors(): Array { + return this._reporters.reduce((list, reporter) => { + const error = reporter.getLastError && reporter.getLastError(); + return error ? list.concat(error) : list; + }, []); + } + + hasErrors(): boolean { + return this.getErrors().length !== 0; + } +} + +module.exports = ReporterDispatcher; diff --git a/packages/jest-cli/src/TestRunner.js b/packages/jest-cli/src/TestRunner.js index d5c23a9d88cf..02eab0d5ada6 100644 --- a/packages/jest-cli/src/TestRunner.js +++ b/packages/jest-cli/src/TestRunner.js @@ -14,11 +14,10 @@ import type { SerializableError as TestError, TestResult, } from 'types/TestResult'; -import type {GlobalConfig} from 'types/Config'; +import type {GlobalConfig, ReporterConfig} from 'types/Config'; import type {Context} from 'types/Context'; import type {PathPattern} from './SearchSource'; -import type {Test} from 'types/TestRunner'; -import type BaseReporter from './reporters/BaseReporter'; +import type {Reporter, Test} from 'types/TestRunner'; const {formatExecError} = require('jest-message-util'); @@ -32,6 +31,7 @@ const snapshot = require('jest-snapshot'); const throat = require('throat'); const workerFarm = require('worker-farm'); const TestWatcher = require('./TestWatcher'); +const ReporterDispatcher = require('./ReporterDispatcher'); const SLOW_TEST_TIME = 3000; @@ -42,7 +42,7 @@ class CancelRun extends Error { } } -export type Options = {| +export type TestRunnerOptions = {| maxWorkers: number, pattern: PathPattern, startRun: () => *, @@ -57,17 +57,17 @@ const TEST_WORKER_PATH = require.resolve('./TestWorker'); class TestRunner { _globalConfig: GlobalConfig; - _options: Options; + _options: TestRunnerOptions; _dispatcher: ReporterDispatcher; - constructor(globalConfig: GlobalConfig, options: Options) { + constructor(globalConfig: GlobalConfig, options: TestRunnerOptions) { this._globalConfig = globalConfig; this._dispatcher = new ReporterDispatcher(); this._options = options; this._setupReporters(); } - addReporter(reporter: BaseReporter) { + addReporter(reporter: Reporter) { this._dispatcher.register(reporter); } @@ -148,7 +148,7 @@ class TestRunner { aggregatedResults.snapshot.filesRemoved)); }; - this._dispatcher.onRunStart(this._globalConfig, aggregatedResults, { + this._dispatcher.onRunStart(aggregatedResults, { estimatedTime, showStatus: !runInBand, }); @@ -165,11 +165,7 @@ class TestRunner { updateSnapshotState(); aggregatedResults.wasInterrupted = watcher.isInterrupted(); - await this._dispatcher.onRunComplete( - contexts, - this._globalConfig, - aggregatedResults, - ); + await this._dispatcher.onRunComplete(contexts, aggregatedResults); const anyTestFailures = !(aggregatedResults.numFailedTests === 0 && aggregatedResults.numRuntimeErrorTestSuites === 0); @@ -280,30 +276,97 @@ class TestRunner { return Promise.race([runAllTests, onInterrupt]).then(cleanup, cleanup); } + _shouldAddDefaultReporters(reporters?: Array): boolean { + return ( + !reporters || + !!reporters.find(reporterConfig => reporterConfig[0] === 'default') + ); + } + _setupReporters() { - const {collectCoverage, expand, notify, verbose} = this._globalConfig; + const {collectCoverage, notify, reporters} = this._globalConfig; - this.addReporter( - verbose - ? new VerboseReporter({expand}) - : new DefaultReporter({verbose: !!verbose}), - ); + const isDefault = this._shouldAddDefaultReporters(reporters); + + if (isDefault) { + this._setupDefaultReporters(); + } + + if (reporters && Array.isArray(reporters)) { + this._addCustomReporters(reporters); + } if (collectCoverage) { // coverage reporter dependency graph is pretty big and we don't // want to require it if we're not in the `--coverage` mode const CoverageReporter = require('./reporters/CoverageReporter'); this.addReporter( - new CoverageReporter({maxWorkers: this._options.maxWorkers}), + new CoverageReporter(this._globalConfig, { + maxWorkers: this._options.maxWorkers, + }), ); } - this.addReporter(new SummaryReporter(this._options)); if (notify) { this.addReporter(new NotifyReporter(this._options.startRun)); } } + _setupDefaultReporters() { + this.addReporter( + this._globalConfig.verbose + ? new VerboseReporter(this._globalConfig) + : new DefaultReporter(this._globalConfig), + ); + + this.addReporter( + new SummaryReporter(this._globalConfig, { + pattern: this._options.pattern, + testNamePattern: this._options.testNamePattern, + testPathPattern: this._options.testPathPattern, + }), + ); + } + + _addCustomReporters(reporters: Array) { + const customReporters = reporters.filter( + reporter => reporter !== 'default', + ); + + customReporters.forEach((reporter, index) => { + const {options, path} = this._getReporterProps(reporter); + + try { + const Reporter = require(path); + this.addReporter(new Reporter(this._globalConfig, options)); + } catch (error) { + throw new Error( + 'An error occured while adding the reporter at path "' + + path + + '".' + + error.message, + ); + } + }); + } + + /** + * Get properties of a reporter in an object + * to make dealing with them less painful. + */ + _getReporterProps( + reporter: ReporterConfig, + ): {path: string, options?: Object} { + if (typeof reporter === 'string') { + return {path: reporter}; + } else if (Array.isArray(reporter)) { + const [path, options] = reporter; + return {options, path}; + } + + throw new Error('Reproter should be either a string or an array'); + } + _bailIfNeeded( contexts: Set, aggregatedResults: AggregatedResult, @@ -315,7 +378,7 @@ class TestRunner { } else { const exit = () => process.exit(1); return this._dispatcher - .onRunComplete(contexts, this._globalConfig, aggregatedResults) + .onRunComplete(contexts, aggregatedResults) .then(exit) .catch(exit); } @@ -438,59 +501,6 @@ const buildFailureTestResult = ( }; }; -class ReporterDispatcher { - _disabled: boolean; - _reporters: Array; - - constructor() { - this._reporters = []; - } - - register(reporter: BaseReporter): void { - this._reporters.push(reporter); - } - - unregister(ReporterClass: Function) { - this._reporters = this._reporters.filter( - reporter => !(reporter instanceof ReporterClass), - ); - } - - onTestResult(test, testResult, results) { - this._reporters.forEach(reporter => - reporter.onTestResult(test, testResult, results), - ); - } - - onTestStart(test) { - this._reporters.forEach(reporter => reporter.onTestStart(test)); - } - - onRunStart(config, results, options) { - this._reporters.forEach(reporter => - reporter.onRunStart(config, results, options), - ); - } - - async onRunComplete(contexts, config, results) { - for (const reporter of this._reporters) { - await reporter.onRunComplete(contexts, config, results); - } - } - - // Return a list of last errors for every reporter - getErrors(): Array { - return this._reporters.reduce((list, reporter) => { - const error = reporter.getLastError(); - return error ? list.concat(error) : list; - }, []); - } - - hasErrors(): boolean { - return this.getErrors().length !== 0; - } -} - const getEstimatedTime = (timings, workers) => { if (!timings.length) { return 0; diff --git a/packages/jest-cli/src/reporters/BaseReporter.js b/packages/jest-cli/src/reporters/BaseReporter.js index 4d97be9f8188..fe1fb4a52bfc 100644 --- a/packages/jest-cli/src/reporters/BaseReporter.js +++ b/packages/jest-cli/src/reporters/BaseReporter.js @@ -10,7 +10,6 @@ 'use strict'; import type {AggregatedResult, TestResult} from 'types/TestResult'; -import type {GlobalConfig} from 'types/Config'; import type {Context} from 'types/Context'; import type {Test} from 'types/TestRunner'; import type {ReporterOnStartOptions} from 'types/Reporters'; @@ -24,11 +23,7 @@ class BaseReporter { process.stderr.write(message + '\n'); } - onRunStart( - globalConfig: GlobalConfig, - results: AggregatedResult, - options: ReporterOnStartOptions, - ) { + onRunStart(results: AggregatedResult, options: ReporterOnStartOptions) { preRunMessage.remove(process.stderr); } @@ -38,9 +33,8 @@ class BaseReporter { onRunComplete( contexts: Set, - globalConfig: GlobalConfig, aggregatedResults: AggregatedResult, - ): ?Promise {} + ): ?Promise {} _setError(error: Error) { this._error = error; diff --git a/packages/jest-cli/src/reporters/CoverageReporter.js b/packages/jest-cli/src/reporters/CoverageReporter.js index 2876b6b934bc..a6a491ff6512 100644 --- a/packages/jest-cli/src/reporters/CoverageReporter.js +++ b/packages/jest-cli/src/reporters/CoverageReporter.js @@ -19,6 +19,10 @@ import type {GlobalConfig} from 'types/Config'; import type {Context} from 'types/Context'; import type {Test} from 'types/TestRunner'; +type CoverageReporterOptions = { + maxWorkers: number, +}; + const BaseReporter = require('./BaseReporter'); const {clearLine} = require('jest-util'); @@ -37,14 +41,16 @@ const isInteractive = process.stdout.isTTY && !isCI; class CoverageReporter extends BaseReporter { _coverageMap: CoverageMap; - _maxWorkers: number; + _globalConfig: GlobalConfig; _sourceMapStore: any; + _maxWorkers: number; - constructor({maxWorkers}: {maxWorkers: number}) { - super(); - this._maxWorkers = maxWorkers; + constructor(globalConfig: GlobalConfig, options: CoverageReporterOptions) { + super(globalConfig); this._coverageMap = istanbulCoverage.createCoverageMap({}); + this._globalConfig = globalConfig; this._sourceMapStore = libSourceMaps.createSourceMapStore(); + this._maxWorkers = options.maxWorkers; } onTestResult( @@ -68,25 +74,24 @@ class CoverageReporter extends BaseReporter { async onRunComplete( contexts: Set, - globalConfig: GlobalConfig, aggregatedResults: AggregatedResult, ) { - await this._addUntestedFiles(globalConfig, contexts); + await this._addUntestedFiles(this._globalConfig, contexts); let map = this._coverageMap; let sourceFinder: Object; - if (globalConfig.mapCoverage) { + if (this._globalConfig.mapCoverage) { ({map, sourceFinder} = this._sourceMapStore.transformCoverage(map)); } const reporter = createReporter(); try { - if (globalConfig.coverageDirectory) { - reporter.dir = globalConfig.coverageDirectory; + if (this._globalConfig.coverageDirectory) { + reporter.dir = this._globalConfig.coverageDirectory; } - let coverageReporters = globalConfig.coverageReporters || []; + let coverageReporters = this._globalConfig.coverageReporters || []; if ( - !globalConfig.useStderr && + !this._globalConfig.useStderr && coverageReporters.length && coverageReporters.indexOf('text') === -1 ) { @@ -108,7 +113,7 @@ class CoverageReporter extends BaseReporter { ); } - this._checkThreshold(globalConfig, map); + this._checkThreshold(this._globalConfig, map); } _addUntestedFiles(globalConfig: GlobalConfig, contexts: Set) { diff --git a/packages/jest-cli/src/reporters/DefaultReporter.js b/packages/jest-cli/src/reporters/DefaultReporter.js index 7d1095a00f33..aa91bbcbc39a 100644 --- a/packages/jest-cli/src/reporters/DefaultReporter.js +++ b/packages/jest-cli/src/reporters/DefaultReporter.js @@ -28,10 +28,6 @@ const isCI = require('is-ci'); type write = (chunk: string, enc?: any, cb?: () => void) => boolean; -type Options = {| - verbose: boolean, -|}; - const TITLE_BULLET = chalk.bold('\u25cf '); const isInteractive = process.stdin.isTTY && !isCI; @@ -39,13 +35,13 @@ const isInteractive = process.stdin.isTTY && !isCI; class DefaultReporter extends BaseReporter { _clear: string; // ANSI clear sequence for the last printed status _err: write; - _options: Options; + _globalConfig: GlobalConfig; _out: write; _status: Status; - constructor(options: Options) { + constructor(globalConfig: GlobalConfig) { super(); - this._options = options; + this._globalConfig = globalConfig; this._clear = ''; this._out = process.stdout.write.bind(process.stdout); this._err = process.stderr.write.bind(process.stderr); @@ -112,7 +108,6 @@ class DefaultReporter extends BaseReporter { } onRunStart( - globalConfig: GlobalConfig, aggregatedResults: AggregatedResult, options: ReporterOnStartOptions, ) { @@ -165,7 +160,7 @@ class DefaultReporter extends BaseReporter { 'Console\n\n' + getConsoleOutput( config.rootDir, - !!this._options.verbose, + !!this._globalConfig.verbose, consoleBuffer, ), ); diff --git a/packages/jest-cli/src/reporters/NotifyReporter.js b/packages/jest-cli/src/reporters/NotifyReporter.js index dd165edb3a00..8181f26b4778 100644 --- a/packages/jest-cli/src/reporters/NotifyReporter.js +++ b/packages/jest-cli/src/reporters/NotifyReporter.js @@ -10,7 +10,6 @@ 'use strict'; import type {AggregatedResult} from 'types/TestResult'; -import type {GlobalConfig} from 'types/Config'; import type {Context} from 'types/Context'; const BaseReporter = require('./BaseReporter'); @@ -30,11 +29,7 @@ class NotifyReporter extends BaseReporter { this._startRun = startRun; } - onRunComplete( - contexts: Set, - globalConfig: GlobalConfig, - result: AggregatedResult, - ): void { + onRunComplete(contexts: Set, result: AggregatedResult): void { const success = result.numFailedTests === 0 && result.numRuntimeErrorTestSuites === 0; diff --git a/packages/jest-cli/src/reporters/SummaryReporter.js b/packages/jest-cli/src/reporters/SummaryReporter.js index eb4a2e33748f..27813f7dd262 100644 --- a/packages/jest-cli/src/reporters/SummaryReporter.js +++ b/packages/jest-cli/src/reporters/SummaryReporter.js @@ -12,10 +12,15 @@ import type {AggregatedResult, SnapshotSummary} from 'types/TestResult'; import type {GlobalConfig} from 'types/Config'; import type {Context} from 'types/Context'; -import type {Options as SummaryReporterOptions} from '../TestRunner'; import type {PathPattern} from '../SearchSource'; import type {ReporterOnStartOptions} from 'types/Reporters'; +type SummaryReporterOptions = {| + pattern: PathPattern, + testNamePattern: string, + testPathPattern: string, +|}; + const BaseReporter = require('./BaseReporter'); const {getSummary, pluralize} = require('./utils'); @@ -60,12 +65,14 @@ const NPM_EVENTS = new Set([ class SummaryReporter extends BaseReporter { _estimatedTime: number; + _globalConfig: GlobalConfig; _options: SummaryReporterOptions; - constructor(options: SummaryReporterOptions) { - super(); - this._options = options; + constructor(globalConfig: GlobalConfig, options: SummaryReporterOptions) { + super(globalConfig); + this._globalConfig = globalConfig; this._estimatedTime = 0; + this._options = options; } // If we write more than one character at a time it is possible that @@ -80,26 +87,21 @@ class SummaryReporter extends BaseReporter { } onRunStart( - globalConfig: GlobalConfig, aggregatedResults: AggregatedResult, options: ReporterOnStartOptions, ) { - super.onRunStart(globalConfig, aggregatedResults, options); + super.onRunStart(aggregatedResults, options); this._estimatedTime = options.estimatedTime; } - onRunComplete( - contexts: Set, - globalConfig: GlobalConfig, - aggregatedResults: AggregatedResult, - ) { + onRunComplete(contexts: Set, aggregatedResults: AggregatedResult) { const {numTotalTestSuites, testResults, wasInterrupted} = aggregatedResults; if (numTotalTestSuites) { const lastResult = testResults[testResults.length - 1]; // Print a newline if the last test did not fail to line up newlines // similar to when an error would have been thrown in the test. if ( - !globalConfig.verbose && + !this._globalConfig.verbose && lastResult && !lastResult.numFailingTests && !lastResult.testExecError @@ -107,8 +109,11 @@ class SummaryReporter extends BaseReporter { this.log(''); } - this._printSummary(aggregatedResults, globalConfig); - this._printSnapshotSummary(aggregatedResults.snapshot, globalConfig); + this._printSummary(aggregatedResults, this._globalConfig); + this._printSnapshotSummary( + aggregatedResults.snapshot, + this._globalConfig, + ); if (numTotalTestSuites) { const testSummary = wasInterrupted diff --git a/packages/jest-cli/src/reporters/VerboseReporter.js b/packages/jest-cli/src/reporters/VerboseReporter.js index 6c10117e751c..471244e8afd8 100644 --- a/packages/jest-cli/src/reporters/VerboseReporter.js +++ b/packages/jest-cli/src/reporters/VerboseReporter.js @@ -9,6 +9,7 @@ */ 'use strict'; +import type {GlobalConfig} from 'types/Config'; import type { AggregatedResult, AssertionResult, @@ -21,16 +22,12 @@ const DefaultReporter = require('./DefaultReporter'); const chalk = require('chalk'); const {ICONS} = require('../constants'); -type Options = {| - expand: boolean, -|}; - class VerboseReporter extends DefaultReporter { - _verboseOptions: Options; + _globalConfig: GlobalConfig; - constructor(options: Options) { - super({verbose: true}); - this._verboseOptions = options; + constructor(globalConfig: GlobalConfig) { + super(globalConfig); + this._globalConfig = globalConfig; } static filterTestResults(testResults: Array) { @@ -101,7 +98,7 @@ class VerboseReporter extends DefaultReporter { } _logTests(tests: Array, indentLevel: number) { - if (this._verboseOptions.expand) { + if (this._globalConfig.expand) { tests.forEach(test => this._logTest(test, indentLevel)); } else { const skippedCount = tests.reduce((result, test) => { diff --git a/packages/jest-cli/src/reporters/__tests__/CoverageReporter-test.js b/packages/jest-cli/src/reporters/__tests__/CoverageReporter-test.js index bfaae36f9862..1b4ec241352d 100644 --- a/packages/jest-cli/src/reporters/__tests__/CoverageReporter-test.js +++ b/packages/jest-cli/src/reporters/__tests__/CoverageReporter-test.js @@ -4,8 +4,6 @@ * 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'; @@ -34,7 +32,6 @@ beforeEach(() => { describe('onRunComplete', () => { let mockAggResults; - let testReporter; beforeEach(() => { mockAggResults = { @@ -76,44 +73,47 @@ describe('onRunComplete', () => { }, }; }); - - testReporter = new CoverageReporter({}); - testReporter.log = jest.fn(); }); it('getLastError() returns an error when threshold is not met', () => { - return testReporter - .onRunComplete( - new Set(), - { - collectCoverage: true, - coverageThreshold: { - global: { - statements: 100, - }, + const testReporter = new CoverageReporter( + { + collectCoverage: true, + coverageThreshold: { + global: { + statements: 100, }, }, - mockAggResults, - ) + }, + { + maxWorkers: 2, + }, + ); + testReporter.log = jest.fn(); + return testReporter + .onRunComplete(new Set(), {}, mockAggResults) .then(() => { expect(testReporter.getLastError()).toBeTruthy(); }); }); it('getLastError() returns `undefined` when threshold is met', () => { - return testReporter - .onRunComplete( - new Set(), - { - collectCoverage: true, - coverageThreshold: { - global: { - statements: 50, - }, + const testReporter = new CoverageReporter( + { + collectCoverage: true, + coverageThreshold: { + global: { + statements: 50, }, }, - mockAggResults, - ) + }, + { + maxWorkers: 2, + }, + ); + testReporter.log = jest.fn(); + return testReporter + .onRunComplete(new Set(), {}, mockAggResults) .then(() => { expect(testReporter.getLastError()).toBeUndefined(); }); diff --git a/packages/jest-config/src/constants.js b/packages/jest-config/src/constants.js index af9b0caa8939..e8f36332dcfc 100644 --- a/packages/jest-config/src/constants.js +++ b/packages/jest-config/src/constants.js @@ -14,3 +14,4 @@ const path = require('path'); exports.NODE_MODULES = path.sep + 'node_modules' + path.sep; exports.DEFAULT_JS_PATTERN = '^.+\\.jsx?$'; +exports.DEFAULT_REPORTER_LABEL = 'default'; diff --git a/packages/jest-config/src/index.js b/packages/jest-config/src/index.js index 1581d952fe69..4cd060d0907a 100644 --- a/packages/jest-config/src/index.js +++ b/packages/jest-config/src/index.js @@ -83,6 +83,7 @@ const getConfigs = ( notify: options.notify, projects: options.projects, replname: options.replname, + reporters: options.reporters, rootDir: options.rootDir, silent: options.silent, testNamePattern: options.testNamePattern, diff --git a/packages/jest-config/src/normalize.js b/packages/jest-config/src/normalize.js index 41a33bb455ee..12b983396f43 100644 --- a/packages/jest-config/src/normalize.js +++ b/packages/jest-config/src/normalize.js @@ -10,7 +10,7 @@ 'use strict'; -import type {InitialOptions} from 'types/Config'; +import type {InitialOptions, ReporterConfig} from 'types/Config'; const { BULLET, @@ -20,7 +20,12 @@ const { getTestEnvironment, resolve, } = require('./utils'); -const {NODE_MODULES, DEFAULT_JS_PATTERN} = require('./constants'); +const { + NODE_MODULES, + DEFAULT_JS_PATTERN, + DEFAULT_REPORTER_LABEL, +} = require('./constants'); +const {validateReporters} = require('./reporterValidationErrors'); const {ValidationError, validate} = require('jest-validate'); const chalk = require('chalk'); const crypto = require('crypto'); @@ -248,6 +253,45 @@ const normalizeArgv = (options: InitialOptions, argv: Object) => { } }; +const normalizeReporters = (options: InitialOptions, basedir) => { + const reporters = options.reporters; + if (!reporters || !Array.isArray(reporters)) { + return options; + } + + validateReporters(reporters); + options.reporters = reporters.map(reporterConfig => { + const normalizedReporterConfig: ReporterConfig = typeof reporterConfig === + 'string' + ? // if reporter config is a string, we wrap it in an array + // and pass an empty object for options argument, to normalize + // the shape. + [reporterConfig, {}] + : reporterConfig; + + const reporterPath = _replaceRootDirInPath( + options.rootDir, + normalizedReporterConfig[0], + ); + + if (reporterPath !== DEFAULT_REPORTER_LABEL) { + const reporter = Resolver.findNodeModule(reporterPath, { + basedir: options.rootDir, + }); + if (!reporter) { + throw new Error( + `Could not resolve a module for a custom reporter.\n` + + ` Module name: ${reporterPath}`, + ); + } + normalizedReporterConfig[0] = reporter; + } + return normalizedReporterConfig; + }); + + return options; +}; + function normalize(options: InitialOptions, argv: Object = {}) { const {hasDeprecationWarnings} = validate(options, { comment: DOCUMENTATION_NOTE, @@ -255,6 +299,7 @@ function normalize(options: InitialOptions, argv: Object = {}) { exampleConfig: VALID_CONFIG, }); + normalizeReporters(options); normalizePreprocessor(options); normalizeRootDir(options); normalizeMissingOptions(options); @@ -374,6 +419,7 @@ function normalize(options: InitialOptions, argv: Object = {}) { case 'notify': case 'preset': case 'replname': + case 'reporters': case 'resetMocks': case 'resetModules': case 'rootDir': diff --git a/packages/jest-config/src/reporterValidationErrors.js b/packages/jest-config/src/reporterValidationErrors.js new file mode 100644 index 000000000000..d6be765f472f --- /dev/null +++ b/packages/jest-config/src/reporterValidationErrors.js @@ -0,0 +1,111 @@ +/** + * 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 {ReporterConfig} from 'types/Config'; + +const {ValidationError} = require('jest-validate'); +const {DOCUMENTATION_NOTE, BULLET} = require('./utils'); + +const chalk = require('chalk'); +const {getType} = require('jest-matcher-utils'); + +const validReporterTypes = ['array', 'string']; +const ERROR = `${BULLET} Reporter Validation Error`; + +/** + * Reporter Vaidation Error is thrown if the given arguments + * within the reporter are not valid. + * + * This is a highly specific reporter error and in the future will be + * merged with jest-validate. Till then, we can make use of it. It works + * and that's what counts most at this time. + */ +function createReporterError( + reporterIndex: number, + reporterValue: Array | string, +): ValidationError { + const errorMessage = + `Reporter at index ${reporterIndex} must be of type:\n` + + ` ${chalk.bold.green(validReporterTypes.join(' or '))}\n` + + ` but instead received:\n` + + ` ${chalk.bold.red(getType(reporterValue))}`; + + return new ValidationError(ERROR, errorMessage, DOCUMENTATION_NOTE); +} + +function createArrayReporterError( + arrayReporter: ReporterConfig, + reporterIndex: number, + valueIndex: number, + value: string | Object, + expectedType: string, + valueName: string, +): ValidationError { + const errorMessage = + `Unexpected value for ${valueName} ` + + `at index ${valueIndex} of reporter at index ${reporterIndex}\n` + + ' Expected:\n' + + ` ${chalk.bold.red(expectedType)}\n` + + ' Got:\n' + + ` ${chalk.bold.green(getType(value))}\n` + + ` Reporter configuration:\n` + + ` ${chalk.bold.green(JSON.stringify(arrayReporter, null, 2) + .split('\n') + .join('\n '))}`; + + return new ValidationError(ERROR, errorMessage, DOCUMENTATION_NOTE); +} + +function validateReporters( + reporterConfig: Array, +): boolean { + return reporterConfig.every((reporter, index) => { + if (Array.isArray(reporter)) { + validateArrayReporter(reporter, index); + } else if (typeof reporter !== 'string') { + throw createReporterError(index, reporter); + } + + return true; + }); +} + +function validateArrayReporter( + arrayReporter: ReporterConfig, + reporterIndex: number, +) { + const [path, options] = arrayReporter; + if (typeof path !== 'string') { + throw createArrayReporterError( + arrayReporter, + reporterIndex, + 0, + path, + 'string', + 'Path', + ); + } else if (typeof options !== 'object') { + throw createArrayReporterError( + arrayReporter, + reporterIndex, + 1, + options, + 'object', + 'Reporter Configuration', + ); + } +} + +module.exports = { + createArrayReporterError, + createReporterError, + validateReporters, +}; diff --git a/packages/jest-config/src/validConfig.js b/packages/jest-config/src/validConfig.js index d27ac429ea98..67f4a3ca014a 100644 --- a/packages/jest-config/src/validConfig.js +++ b/packages/jest-config/src/validConfig.js @@ -58,6 +58,11 @@ module.exports = ({ notify: false, preset: 'react-native', projects: ['project-a', 'project-b/'], + reporters: [ + 'default', + 'custom-reporter-1', + ['custom-reporter-2', {configValue: true}], + ], resetMocks: false, resetModules: false, resolver: '/resolver.js', diff --git a/types/Config.js b/types/Config.js index d5a013f88d09..5d75502e73dc 100644 --- a/types/Config.js +++ b/types/Config.js @@ -19,6 +19,8 @@ export type HasteConfig = {| providesModuleNodeModules: Array, |}; +export type ReporterConfig = [string, Object]; + export type ConfigGlobals = Object; export type DefaultOptions = {| @@ -75,6 +77,7 @@ export type InitialOptions = {| forceExit?: boolean, globals?: ConfigGlobals, haste?: HasteConfig, + reporters?: Array, logHeapUsage?: boolean, mapCoverage?: boolean, moduleDirectories?: Array, @@ -135,6 +138,7 @@ export type GlobalConfig = {| notify: boolean, projects: Array, replname: ?string, + reporters: Array, rootDir: Path, silent: boolean, testNamePattern: string, diff --git a/types/TestRunner.js b/types/TestRunner.js index 4084d5d87525..bee1b96e3d11 100644 --- a/types/TestRunner.js +++ b/types/TestRunner.js @@ -12,7 +12,8 @@ import type {Context} from './Context'; import type {Environment} from 'types/Environment'; import type {GlobalConfig, Path, ProjectConfig} from './Config'; -import type {TestResult} from 'types/TestResult'; +import type {ReporterOnStartOptions} from 'types/Reporters'; +import type {TestResult, AggregatedResult} from 'types/TestResult'; import type Runtime from 'jest-runtime'; export type Test = {| @@ -21,6 +22,24 @@ export type Test = {| duration: ?number, |}; +export type Reporter = { + +onTestResult: ( + test: Test, + testResult: TestResult, + aggregatedResult: AggregatedResult, + ) => void, + +onRunStart: ( + results: AggregatedResult, + options: ReporterOnStartOptions, + ) => void, + +onTestStart: (test: Test) => void, + +onRunComplete: ( + contexts: Set, + results: AggregatedResult, + ) => ?Promise, + +getLastError: () => ?Error, +}; + export type TestFramework = ( globalConfig: GlobalConfig, config: ProjectConfig,