diff --git a/TestUtils.ts b/TestUtils.ts index aa4f3c345dca..1398da6b1ba3 100644 --- a/TestUtils.ts +++ b/TestUtils.ts @@ -16,6 +16,7 @@ const DEFAULT_GLOBAL_CONFIG: Config.GlobalConfig = { collectCoverageFrom: [], collectCoverageOnlyFrom: null, coverageDirectory: 'coverage', + coverageProvider: 'babel', coverageReporters: [], coverageThreshold: {global: {}}, detectLeaks: false, diff --git a/docs/CLI.md b/docs/CLI.md index de9bee433d67..09432df11646 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -152,6 +152,16 @@ Alias: `-c`. The path to a Jest config file specifying how to find and execute t Alias: `--collectCoverage`. Indicates that test coverage information should be collected and reported in the output. Optionally pass `` to override option set in configuration. +### `--coverageProvider=` + +Indicates which provider should be used to instrument code for coverage. Allowed values are `babel` (default) or `v8`. + +Note that using `v8` is considered experimental. This uses V8's builtin code coverage rather than one based on Babel and comes with a few caveats + +1. Your node version must include `vm.compileFunction`, which was introduced in [node 10.10](https://nodejs.org/dist/latest-v12.x/docs/api/vm.html#vm_vm_compilefunction_code_params_options) +1. Tests needs to run in Node test environment (support for `jsdom` is in the works, see [#9315](https://github.com/facebook/jest/issues/9315)) +1. V8 has way better data in the later versions, so using the latest versions of node (v13 at the time of this writing) will yield better results + ### `--debug` Print debugging info about your Jest config. diff --git a/docs/Configuration.md b/docs/Configuration.md index 709686c84c38..266196ff85c6 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -185,6 +185,16 @@ An array of regexp pattern strings that are matched against all file paths befor These pattern strings match against the full path. Use the `` string token to include the path to your project's root directory to prevent it from accidentally ignoring all of your files in different environments that may have different root directories. Example: `["/build/", "/node_modules/"]`. +### `coverageProvider` [string] + +Indicates which provider should be used to instrument code for coverage. Allowed values are `babel` (default) or `v8`. + +Note that using `v8` is considered experimental. This uses V8's builtin code coverage rather than one based on Babel and comes with a few caveats + +1. Your node version must include `vm.compileFunction`, which was introduced in [node 10.10](https://nodejs.org/dist/latest-v12.x/docs/api/vm.html#vm_vm_compilefunction_code_params_options) +1. Tests needs to run in Node test environment (support for `jsdom` is in the works, see [#9315](https://github.com/facebook/jest/issues/9315)) +1. V8 has way better data in the later versions, so using the latest versions of node (v13 at the time of this writing) will yield better results + ### `coverageReporters` [array\] Default: `["json", "lcov", "text", "clover"]` diff --git a/e2e/__tests__/__snapshots__/showConfig.test.ts.snap b/e2e/__tests__/__snapshots__/showConfig.test.ts.snap index e7bd2b9c3273..7f3d73efee7c 100644 --- a/e2e/__tests__/__snapshots__/showConfig.test.ts.snap +++ b/e2e/__tests__/__snapshots__/showConfig.test.ts.snap @@ -84,6 +84,7 @@ exports[`--showConfig outputs config info and exits 1`] = ` "collectCoverage": false, "collectCoverageFrom": [], "coverageDirectory": "<>/coverage", + "coverageProvider": "babel", "coverageReporters": [ "json", "text", diff --git a/e2e/__tests__/v8Coverage.test.ts b/e2e/__tests__/v8Coverage.test.ts new file mode 100644 index 000000000000..97dfea44252f --- /dev/null +++ b/e2e/__tests__/v8Coverage.test.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as path from 'path'; +import {onNodeVersions} from '@jest/test-utils'; +import runJest from '../runJest'; + +const DIR = path.resolve(__dirname, '../v8-coverage'); + +onNodeVersions('>=10', () => { + test('prints coverage', () => { + const sourcemapDir = path.join(DIR, 'no-sourcemap'); + const {stdout, exitCode} = runJest( + sourcemapDir, + ['--coverage', '--coverage-provider', 'v8'], + { + stripAnsi: true, + }, + ); + + expect(exitCode).toBe(0); + expect( + '\n' + + stdout + .split('\n') + .map(s => s.trimRight()) + .join('\n') + + '\n', + ).toEqual(` + console.log __tests__/Thing.test.js:10 + 42 + +----------|---------|----------|---------|---------|------------------- +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s +----------|---------|----------|---------|---------|------------------- +All files | 100 | 100 | 100 | 100 | + Thing.js | 100 | 100 | 100 | 100 | + x.css | 100 | 100 | 100 | 100 | +----------|---------|----------|---------|---------|------------------- +`); + }); +}); diff --git a/e2e/v8-coverage/no-sourcemap/Thing.js b/e2e/v8-coverage/no-sourcemap/Thing.js new file mode 100644 index 000000000000..95f6bd8cc066 --- /dev/null +++ b/e2e/v8-coverage/no-sourcemap/Thing.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +require('./x.css'); + +module.exports = 42; diff --git a/e2e/v8-coverage/no-sourcemap/__tests__/Thing.test.js b/e2e/v8-coverage/no-sourcemap/__tests__/Thing.test.js new file mode 100644 index 000000000000..aa7f6940580f --- /dev/null +++ b/e2e/v8-coverage/no-sourcemap/__tests__/Thing.test.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +const Thing = require('../Thing'); + +console.log(Thing); +test.todo('whatever'); diff --git a/e2e/v8-coverage/no-sourcemap/cssTransform.js b/e2e/v8-coverage/no-sourcemap/cssTransform.js new file mode 100644 index 000000000000..c973ad34fe44 --- /dev/null +++ b/e2e/v8-coverage/no-sourcemap/cssTransform.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +module.exports = { + getCacheKey: () => 'cssTransform', + process: () => 'module.exports = {};', +}; diff --git a/e2e/v8-coverage/no-sourcemap/package.json b/e2e/v8-coverage/no-sourcemap/package.json new file mode 100644 index 000000000000..033719f58b9b --- /dev/null +++ b/e2e/v8-coverage/no-sourcemap/package.json @@ -0,0 +1,11 @@ +{ + "name": "no-sourcemap", + "version": "1.0.0", + "jest": { + "testEnvironment": "node", + "transform": { + "^.+\\.[jt]sx?$": "babel-jest", + "^.+\\.css$": "/cssTransform.js" + } + } +} diff --git a/e2e/v8-coverage/no-sourcemap/x.css b/e2e/v8-coverage/no-sourcemap/x.css new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/jest.config.js b/jest.config.js index a39a9f4789f5..cb189da42604 100644 --- a/jest.config.js +++ b/jest.config.js @@ -65,6 +65,7 @@ module.exports = { transform: { '^.+\\.[jt]sx?$': '/packages/babel-jest', }, + watchPathIgnorePatterns: ['coverage'], watchPlugins: [ 'jest-watch-typeahead/filename', 'jest-watch-typeahead/testname', diff --git a/package.json b/package.json index b3fae3325970..9cf31541677f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "isbinaryfile": "^4.0.0", "istanbul-lib-coverage": "^3.0.0-alpha.1", "istanbul-lib-report": "^3.0.0-alpha.1", - "istanbul-reports": "^3.0.0-alpha.4", + "istanbul-reports": "^3.0.0-alpha.5", "jest-junit": "^9.0.0", "jest-silent-reporter": "^0.1.2", "jest-snapshot-serializer-raw": "^1.1.0", diff --git a/packages/jest-cli/src/cli/args.ts b/packages/jest-cli/src/cli/args.ts index 6bc9650b7a01..a0d3f191b722 100644 --- a/packages/jest-cli/src/cli/args.ts +++ b/packages/jest-cli/src/cli/args.ts @@ -202,6 +202,11 @@ export const options = { string: true, type: 'array', }, + coverageProvider: { + choices: ['babel', 'v8'], + default: 'babel', + description: 'Select between Babel and V8 to collect coverage', + }, coverageReporters: { description: 'A list of reporter names that Jest uses when writing ' + diff --git a/packages/jest-config/src/Defaults.ts b/packages/jest-config/src/Defaults.ts index e096029b7795..af912c569d2c 100644 --- a/packages/jest-config/src/Defaults.ts +++ b/packages/jest-config/src/Defaults.ts @@ -22,6 +22,7 @@ const defaultOptions: Config.DefaultOptions = { clearMocks: false, collectCoverage: false, coveragePathIgnorePatterns: [NODE_MODULES_REGEXP], + coverageProvider: 'babel', coverageReporters: ['json', 'text', 'lcov', 'clover'], errorOnDeprecated: false, expand: false, diff --git a/packages/jest-config/src/ValidConfig.ts b/packages/jest-config/src/ValidConfig.ts index 28178876d529..b07cdb441f2c 100644 --- a/packages/jest-config/src/ValidConfig.ts +++ b/packages/jest-config/src/ValidConfig.ts @@ -28,6 +28,7 @@ const initialOptions: Config.InitialOptions = { }, coverageDirectory: 'coverage', coveragePathIgnorePatterns: [NODE_MODULES_REGEXP], + coverageProvider: 'v8', coverageReporters: ['json', 'text', 'lcov', 'clover'], coverageThreshold: { global: { diff --git a/packages/jest-config/src/index.ts b/packages/jest-config/src/index.ts index eb392317c02b..552ba860af37 100644 --- a/packages/jest-config/src/index.ts +++ b/packages/jest-config/src/index.ts @@ -110,6 +110,7 @@ const groupOptions = ( collectCoverageFrom: options.collectCoverageFrom, collectCoverageOnlyFrom: options.collectCoverageOnlyFrom, coverageDirectory: options.coverageDirectory, + coverageProvider: options.coverageProvider, coverageReporters: options.coverageReporters, coverageThreshold: options.coverageThreshold, detectLeaks: options.detectLeaks, diff --git a/packages/jest-config/src/normalize.ts b/packages/jest-config/src/normalize.ts index 96111241f9d3..f0cf0213eee0 100644 --- a/packages/jest-config/src/normalize.ts +++ b/packages/jest-config/src/normalize.ts @@ -836,6 +836,7 @@ export default function normalize( case 'changedFilesWithAncestor': case 'clearMocks': case 'collectCoverage': + case 'coverageProvider': case 'coverageReporters': case 'coverageThreshold': case 'detectLeaks': diff --git a/packages/jest-core/src/lib/__tests__/__snapshots__/log_debug_messages.test.ts.snap b/packages/jest-core/src/lib/__tests__/__snapshots__/log_debug_messages.test.ts.snap index 0f4ceaeb6a3b..3be6c4ebb716 100644 --- a/packages/jest-core/src/lib/__tests__/__snapshots__/log_debug_messages.test.ts.snap +++ b/packages/jest-core/src/lib/__tests__/__snapshots__/log_debug_messages.test.ts.snap @@ -71,6 +71,7 @@ exports[`prints the config object 1`] = ` "collectCoverageFrom": [], "collectCoverageOnlyFrom": null, "coverageDirectory": "coverage", + "coverageProvider": "babel", "coverageReporters": [], "coverageThreshold": { "global": {} diff --git a/packages/jest-reporters/package.json b/packages/jest-reporters/package.json index 4e24c60050af..aae31a9d81b9 100644 --- a/packages/jest-reporters/package.json +++ b/packages/jest-reporters/package.json @@ -5,19 +5,21 @@ "main": "build/index.js", "types": "build/index.d.ts", "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "^24.9.0", "@jest/environment": "^24.9.0", "@jest/test-result": "^24.9.0", "@jest/transform": "^24.9.0", "@jest/types": "^24.9.0", "chalk": "^3.0.0", + "collect-v8-coverage": "^1.0.0", "exit": "^0.1.2", "glob": "^7.1.2", "istanbul-lib-coverage": "^3.0.0-alpha.1", "istanbul-lib-instrument": "^4.0.0-alpha.2", "istanbul-lib-report": "^3.0.0-alpha.1", "istanbul-lib-source-maps": "^4.0.0-alpha.4", - "istanbul-reports": "^3.0.0-alpha.4", + "istanbul-reports": "^3.0.0-alpha.5", "jest-haste-map": "^24.9.0", "jest-resolve": "^24.9.0", "jest-runtime": "^24.9.0", @@ -26,7 +28,8 @@ "slash": "^3.0.0", "source-map": "^0.6.0", "string-length": "^3.1.0", - "terminal-link": "^2.0.0" + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^4.0.1" }, "devDependencies": { "@types/exit": "^0.1.30", diff --git a/packages/jest-reporters/src/coverage_reporter.ts b/packages/jest-reporters/src/coverage_reporter.ts index 7fefd26508ac..39025a4e5e51 100644 --- a/packages/jest-reporters/src/coverage_reporter.ts +++ b/packages/jest-reporters/src/coverage_reporter.ts @@ -6,20 +6,34 @@ */ import * as path from 'path'; +import * as fs from 'fs'; import {Config} from '@jest/types'; -import {AggregatedResult, TestResult} from '@jest/test-result'; +import { + AggregatedResult, + TestResult, + V8CoverageResult, +} from '@jest/test-result'; import {clearLine, isInteractive} from 'jest-util'; import istanbulReport = require('istanbul-lib-report'); import istanbulReports = require('istanbul-reports'); import chalk = require('chalk'); import istanbulCoverage = require('istanbul-lib-coverage'); import libSourceMaps = require('istanbul-lib-source-maps'); +import {mergeProcessCovs} from '@bcoe/v8-coverage'; import Worker from 'jest-worker'; import glob = require('glob'); +import v8toIstanbul = require('v8-to-istanbul'); import {RawSourceMap} from 'source-map'; +import {TransformResult} from '@jest/transform'; import BaseReporter from './base_reporter'; import {Context, CoverageReporterOptions, CoverageWorker, Test} from './types'; +// This is fixed in a newer versions of source-map, but our dependencies are still stuck on old versions +interface FixedRawSourceMap extends Omit { + version: number; + file: string; +} + const FAIL_COLOR = chalk.bold.red; const RUNNING_TEST_COLOR = chalk.bold.dim; @@ -28,6 +42,7 @@ export default class CoverageReporter extends BaseReporter { private _globalConfig: Config.GlobalConfig; private _sourceMapStore: libSourceMaps.MapStore; private _options: CoverageReporterOptions; + private _v8CoverageResults: Array; constructor( globalConfig: Config.GlobalConfig, @@ -37,14 +52,16 @@ export default class CoverageReporter extends BaseReporter { this._coverageMap = istanbulCoverage.createCoverageMap({}); this._globalConfig = globalConfig; this._sourceMapStore = libSourceMaps.createSourceMapStore(); + this._v8CoverageResults = []; this._options = options || {}; } - onTestResult( - _test: Test, - testResult: TestResult, - _aggregatedResults: AggregatedResult, - ) { + onTestResult(_test: Test, testResult: TestResult) { + if (testResult.v8Coverage) { + this._v8CoverageResults.push(testResult.v8Coverage); + return; + } + if (testResult.coverage) { this._coverageMap.merge(testResult.coverage); } @@ -76,23 +93,15 @@ export default class CoverageReporter extends BaseReporter { contexts: Set, aggregatedResults: AggregatedResult, ) { - await this._addUntestedFiles(this._globalConfig, contexts); - const map = await this._sourceMapStore.transformCoverage(this._coverageMap); + await this._addUntestedFiles(contexts); + const {map, reportContext} = await this._getCoverageResult(); try { - const reportContext = istanbulReport.createContext({ - // @ts-ignore - coverageMap: map, - dir: this._globalConfig.coverageDirectory, - // @ts-ignore - sourceFinder: this._sourceMapStore.sourceFinder, - }); const coverageReporters = this._globalConfig.coverageReporters || []; if (!this._globalConfig.useStderr && coverageReporters.length < 1) { coverageReporters.push('text-summary'); } - coverageReporters.forEach(reporter => { istanbulReports .create(reporter, {maxCols: process.stdout.columns || Infinity}) @@ -115,20 +124,20 @@ export default class CoverageReporter extends BaseReporter { this._checkThreshold(map); } - private async _addUntestedFiles( - globalConfig: Config.GlobalConfig, - contexts: Set, - ): Promise { + private async _addUntestedFiles(contexts: Set): Promise { const files: Array<{config: Config.ProjectConfig; path: string}> = []; contexts.forEach(context => { const config = context.config; if ( - globalConfig.collectCoverageFrom && - globalConfig.collectCoverageFrom.length + this._globalConfig.collectCoverageFrom && + this._globalConfig.collectCoverageFrom.length ) { context.hasteFS - .matchFilesWithGlob(globalConfig.collectCoverageFrom, config.rootDir) + .matchFilesWithGlob( + this._globalConfig.collectCoverageFrom, + config.rootDir, + ) .forEach(filePath => files.push({ config, @@ -164,11 +173,19 @@ export default class CoverageReporter extends BaseReporter { const filename = fileObj.path; const config = fileObj.config; - if (!this._coverageMap.data[filename] && 'worker' in worker) { + const hasCoverageData = this._v8CoverageResults.some(v8Res => + v8Res.some(innerRes => innerRes.result.url === filename), + ); + + if ( + !hasCoverageData && + !this._coverageMap.data[filename] && + 'worker' in worker + ) { try { const result = await worker.worker({ config, - globalConfig, + globalConfig: this._globalConfig, options: { ...this._options, changedFiles: @@ -179,10 +196,19 @@ export default class CoverageReporter extends BaseReporter { }); if (result) { - this._coverageMap.addFileCoverage(result.coverage); - - if (result.sourceMapPath) { - this._sourceMapStore.registerURL(filename, result.sourceMapPath); + if (result.kind === 'V8Coverage') { + this._v8CoverageResults.push([ + {codeTransformResult: undefined, result: result.result}, + ]); + } else { + this._coverageMap.addFileCoverage(result.coverage); + + if (result.sourceMapPath) { + this._sourceMapStore.registerURL( + filename, + result.sourceMapPath, + ); + } } } } catch (error) { @@ -210,7 +236,7 @@ export default class CoverageReporter extends BaseReporter { } if (worker && 'end' in worker && typeof worker.end === 'function') { - worker.end(); + await worker.end(); } } @@ -405,8 +431,84 @@ export default class CoverageReporter extends BaseReporter { } } - // Only exposed for the internal runner. Should not be used - getCoverageMap(): istanbulCoverage.CoverageMap { - return this._coverageMap; + private async _getCoverageResult(): Promise<{ + map: istanbulCoverage.CoverageMap; + reportContext: istanbulReport.Context; + }> { + if (this._globalConfig.coverageProvider === 'v8') { + const mergedCoverages = mergeProcessCovs( + this._v8CoverageResults.map(cov => ({result: cov.map(r => r.result)})), + ); + + const fileTransforms = new Map(); + + this._v8CoverageResults.forEach(res => + res.forEach(r => { + if (r.codeTransformResult && !fileTransforms.has(r.result.url)) { + fileTransforms.set(r.result.url, r.codeTransformResult); + } + }), + ); + + const transformedCoverage = await Promise.all( + mergedCoverages.result.map(async res => { + const fileTransform = fileTransforms.get(res.url); + + let sourcemapContent: FixedRawSourceMap | undefined = undefined; + + if ( + fileTransform && + fileTransform.sourceMapPath && + fs.existsSync(fileTransform.sourceMapPath) + ) { + sourcemapContent = JSON.parse( + fs.readFileSync(fileTransform.sourceMapPath, 'utf8'), + ); + } + + const converter = v8toIstanbul( + res.url, + 0, + fileTransform && sourcemapContent + ? { + originalSource: fileTransform.originalCode, + source: fileTransform.code, + sourceMap: {sourcemap: sourcemapContent}, + } + : {source: fs.readFileSync(res.url, 'utf8')}, + ); + + await converter.load(); + + converter.applyCoverage(res.functions); + + return converter.toIstanbul(); + }), + ); + + const map = istanbulCoverage.createCoverageMap({}); + + transformedCoverage.forEach(res => map.merge(res)); + + const reportContext = istanbulReport.createContext({ + // @ts-ignore + coverageMap: map, + dir: this._globalConfig.coverageDirectory, + }); + + return {map, reportContext}; + } + + const map = await this._sourceMapStore.transformCoverage(this._coverageMap); + const reportContext = istanbulReport.createContext({ + // @ts-ignore + coverageMap: map, + dir: this._globalConfig.coverageDirectory, + // @ts-ignore + sourceFinder: this._sourceMapStore.sourceFinder, + }); + + // @ts-ignore + return {map, reportContext}; } } diff --git a/packages/jest-reporters/src/generateEmptyCoverage.ts b/packages/jest-reporters/src/generateEmptyCoverage.ts index c9420ff6bdc2..b026e9861b3e 100644 --- a/packages/jest-reporters/src/generateEmptyCoverage.ts +++ b/packages/jest-reporters/src/generateEmptyCoverage.ts @@ -5,15 +5,25 @@ * LICENSE file in the root directory of this source tree. */ +import * as fs from 'fs'; import {Config} from '@jest/types'; import {readInitialCoverage} from 'istanbul-lib-instrument'; -import {createFileCoverage} from 'istanbul-lib-coverage'; +import {FileCoverage, createFileCoverage} from 'istanbul-lib-coverage'; import {ScriptTransformer, shouldInstrument} from '@jest/transform'; +import {V8Coverage} from 'collect-v8-coverage'; -export type CoverageWorkerResult = { - coverage: any; - sourceMapPath?: string | null; -}; +type SingleV8Coverage = V8Coverage[number]; + +export type CoverageWorkerResult = + | { + kind: 'BabelCoverage'; + coverage: FileCoverage; + sourceMapPath?: string | null; + } + | { + kind: 'V8Coverage'; + result: SingleV8Coverage; + }; export default function( source: string, @@ -27,9 +37,34 @@ export default function( collectCoverage: globalConfig.collectCoverage, collectCoverageFrom: globalConfig.collectCoverageFrom, collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom, + coverageProvider: globalConfig.coverageProvider, }; let coverageWorkerResult: CoverageWorkerResult | null = null; if (shouldInstrument(filename, coverageOptions, config)) { + if (coverageOptions.coverageProvider === 'v8') { + const stat = fs.statSync(filename); + return { + kind: 'V8Coverage', + result: { + functions: [ + { + functionName: '(empty-report)', + isBlockCoverage: true, + ranges: [ + { + count: 0, + endOffset: stat.size, + startOffset: 0, + }, + ], + }, + ], + scriptId: '0', + url: filename, + }, + }; + } + // Transform file with instrumentation to make sure initial coverage data is well mapped to original code. const {code, mapCoverage, sourceMapPath} = new ScriptTransformer( config, @@ -40,6 +75,7 @@ export default function( if (extracted) { coverageWorkerResult = { coverage: createFileCoverage(extracted.coverageData), + kind: 'BabelCoverage', sourceMapPath: mapCoverage ? sourceMapPath : null, }; } diff --git a/packages/jest-runner/src/runTest.ts b/packages/jest-runner/src/runTest.ts index 6786e0e172ae..5d0232a28c08 100644 --- a/packages/jest-runner/src/runTest.ts +++ b/packages/jest-runner/src/runTest.ts @@ -157,6 +157,7 @@ async function runTestInternal( collectCoverage: globalConfig.collectCoverage, collectCoverageFrom: globalConfig.collectCoverageFrom, collectCoverageOnlyFrom: globalConfig.collectCoverageOnlyFrom, + coverageProvider: globalConfig.coverageProvider, }); const start = Date.now(); @@ -218,12 +219,20 @@ async function runTestInternal( }; } + // if we don't have `compileFunction` on the env, skip coverage + const collectV8Coverage = + globalConfig.coverageProvider === 'v8' && + typeof environment.compileFunction === 'function'; + try { await environment.setup(); let result: TestResult; try { + if (collectV8Coverage) { + await runtime.collectV8Coverage(); + } result = await testFramework( globalConfig, config, @@ -236,6 +245,10 @@ async function runTestInternal( err.stack; throw err; + } finally { + if (collectV8Coverage) { + await runtime.stopCollectingV8Coverage(); + } } freezeConsole(testConsole, config); @@ -261,6 +274,13 @@ async function runTestInternal( } } + if (collectV8Coverage) { + const v8Coverage = runtime.getAllV8CoverageInfoCopy(); + if (v8Coverage && v8Coverage.length > 0) { + result.v8Coverage = v8Coverage; + } + } + if (globalConfig.logHeapUsage) { if (global.gc) { global.gc(); diff --git a/packages/jest-runtime/package.json b/packages/jest-runtime/package.json index 1915713a8dbf..0f2785202dd4 100644 --- a/packages/jest-runtime/package.json +++ b/packages/jest-runtime/package.json @@ -13,10 +13,12 @@ "@jest/console": "^24.7.1", "@jest/environment": "^24.9.0", "@jest/source-map": "^24.3.0", + "@jest/test-result": "^24.9.0", "@jest/transform": "^24.9.0", "@jest/types": "^24.9.0", "@types/yargs": "^13.0.0", "chalk": "^3.0.0", + "collect-v8-coverage": "^1.0.0", "exit": "^0.1.2", "glob": "^7.1.3", "graceful-fs": "^4.2.3", diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index a082739ba7fb..aa224ccf8d55 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import {Script} from 'vm'; +import {fileURLToPath} from 'url'; import {Config} from '@jest/types'; import { Jest, @@ -26,10 +27,13 @@ import Snapshot = require('jest-snapshot'); import { ScriptTransformer, ShouldInstrumentOptions, + TransformResult, TransformationOptions, handlePotentialSyntaxError, shouldInstrument, } from '@jest/transform'; +import {V8CoverageResult} from '@jest/test-result'; +import {CoverageInstrumenter, V8Coverage} from 'collect-v8-coverage'; import * as fs from 'graceful-fs'; import stripBOM = require('strip-bom'); import {run as cliRun} from './cli'; @@ -111,6 +115,9 @@ class Runtime { private _shouldUnmockTransitiveDependenciesCache: BooleanObject; private _sourceMapRegistry: SourceMapRegistry; private _scriptTransformer: ScriptTransformer; + private _fileTransforms: Map; + private _v8CoverageInstrumenter: CoverageInstrumenter | undefined; + private _v8CoverageResult: V8Coverage | undefined; private _transitiveShouldMock: BooleanObject; private _unmockList: RegExp | undefined; private _virtualMocks: BooleanObject; @@ -129,6 +136,7 @@ class Runtime { collectCoverage: false, collectCoverageFrom: [], collectCoverageOnlyFrom: undefined, + coverageProvider: 'babel', }; this._currentlyExecutingModulePath = ''; this._environment = environment; @@ -147,6 +155,7 @@ class Runtime { this._scriptTransformer = new ScriptTransformer(config); this._shouldAutoMock = config.automock; this._sourceMapRegistry = Object.create(null); + this._fileTransforms = new Map(); this._virtualMocks = Object.create(null); this._mockMetaDataCache = Object.create(null); @@ -494,6 +503,7 @@ class Runtime { collectCoverage: this._coverageOptions.collectCoverage, collectCoverageFrom: this._coverageOptions.collectCoverageFrom, collectCoverageOnlyFrom: this._coverageOptions.collectCoverageOnlyFrom, + coverageProvider: this._coverageOptions.coverageProvider, }; } @@ -560,10 +570,48 @@ class Runtime { } } + async collectV8Coverage() { + this._v8CoverageInstrumenter = new CoverageInstrumenter(); + + await this._v8CoverageInstrumenter.startInstrumenting(); + } + + async stopCollectingV8Coverage() { + if (!this._v8CoverageInstrumenter) { + throw new Error('You need to call `collectV8Coverage` first.'); + } + this._v8CoverageResult = await this._v8CoverageInstrumenter.stopInstrumenting(); + } + getAllCoverageInfoCopy() { return deepCyclicCopy(this._environment.global.__coverage__); } + getAllV8CoverageInfoCopy(): V8CoverageResult { + if (!this._v8CoverageResult) { + throw new Error('You need to `stopCollectingV8Coverage` first'); + } + + return this._v8CoverageResult + .filter(res => res.url.startsWith('file://')) + .map(res => ({...res, url: fileURLToPath(res.url)})) + .filter( + res => + // TODO: will this work on windows? It might be better if `shouldInstrument` deals with it anyways + res.url.startsWith(this._config.rootDir) && + this._fileTransforms.has(res.url) && + shouldInstrument(res.url, this._coverageOptions, this._config), + ) + .map(result => { + const transformedFile = this._fileTransforms.get(result.url); + + return { + codeTransformResult: transformedFile, + result, + }; + }); + } + getSourceMapInfo(coveredFiles: Set) { return Object.keys(this._sourceMapRegistry).reduce<{ [path: string]: string; @@ -723,6 +771,11 @@ class Runtime { this._cacheFS[filename], ); + // we only care about non-internal modules + if (!options || !options.isInternalModule) { + this._fileTransforms.set(filename, transformedFile); + } + if (transformedFile.sourceMapPath) { this._sourceMapRegistry[filename] = transformedFile.sourceMapPath; if (transformedFile.mapCoverage) { diff --git a/packages/jest-runtime/tsconfig.json b/packages/jest-runtime/tsconfig.json index 2892d4eb078d..23c5279939a3 100644 --- a/packages/jest-runtime/tsconfig.json +++ b/packages/jest-runtime/tsconfig.json @@ -16,6 +16,7 @@ {"path": "../jest-resolve"}, {"path": "../jest-snapshot"}, {"path": "../jest-source-map"}, + {"path": "../jest-test-result"}, {"path": "../jest-types"}, {"path": "../jest-util"}, {"path": "../jest-validate"}, diff --git a/packages/jest-test-result/package.json b/packages/jest-test-result/package.json index cf0b720e565c..69c636029eb0 100644 --- a/packages/jest-test-result/package.json +++ b/packages/jest-test-result/package.json @@ -11,8 +11,10 @@ "types": "build/index.d.ts", "dependencies": { "@jest/console": "^24.9.0", + "@jest/transform": "^24.9.0", "@jest/types": "^24.9.0", - "@types/istanbul-lib-coverage": "^2.0.0" + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" }, "engines": { "node": ">= 8" diff --git a/packages/jest-test-result/src/index.ts b/packages/jest-test-result/src/index.ts index b5feb16ae93e..335fc89d1932 100644 --- a/packages/jest-test-result/src/index.ts +++ b/packages/jest-test-result/src/index.ts @@ -24,4 +24,5 @@ export { Status, Suite, TestResult, + V8CoverageResult, } from './types'; diff --git a/packages/jest-test-result/src/types.ts b/packages/jest-test-result/src/types.ts index a9427cd7abb2..7a19b782d6d0 100644 --- a/packages/jest-test-result/src/types.ts +++ b/packages/jest-test-result/src/types.ts @@ -9,6 +9,13 @@ import {CoverageMap, CoverageMapData} from 'istanbul-lib-coverage'; import {ConsoleBuffer} from '@jest/console'; import {Config} from '@jest/types'; +import {V8Coverage} from 'collect-v8-coverage'; +import {TransformResult} from '@jest/transform'; + +export type V8CoverageResult = Array<{ + codeTransformResult: TransformResult | undefined; + result: V8Coverage[number]; +}>; export type SerializableError = { code?: unknown; @@ -132,6 +139,7 @@ export type TestResult = { testExecError?: SerializableError; testFilePath: string; testResults: Array; + v8Coverage?: V8CoverageResult; }; export type FormattedTestResult = { diff --git a/packages/jest-test-result/tsconfig.json b/packages/jest-test-result/tsconfig.json index 4a3022904a19..c8ba8adff346 100644 --- a/packages/jest-test-result/tsconfig.json +++ b/packages/jest-test-result/tsconfig.json @@ -6,6 +6,7 @@ }, "references": [ {"path": "../jest-console"}, + {"path": "../jest-transform"}, {"path": "../jest-types"} ] } diff --git a/packages/jest-transform/src/ScriptTransformer.ts b/packages/jest-transform/src/ScriptTransformer.ts index 9ab22cac463e..e8b30cce2500 100644 --- a/packages/jest-transform/src/ScriptTransformer.ts +++ b/packages/jest-transform/src/ScriptTransformer.ts @@ -374,6 +374,7 @@ export default class ScriptTransformer { return { code, mapCoverage, + originalCode: content, sourceMapPath, }; } catch (e) { @@ -390,7 +391,9 @@ export default class ScriptTransformer { let instrument = false; if (!options.isCoreModule) { - instrument = shouldInstrument(filename, options, this._config); + instrument = + options.coverageProvider === 'babel' && + shouldInstrument(filename, options, this._config); scriptCacheKey = getScriptCacheKey(filename, instrument); const result = this._cache.transformedFiles.get(scriptCacheKey); if (result) { diff --git a/packages/jest-transform/src/index.ts b/packages/jest-transform/src/index.ts index 392b79c9a410..73a36d70b1bf 100644 --- a/packages/jest-transform/src/index.ts +++ b/packages/jest-transform/src/index.ts @@ -14,5 +14,6 @@ export { Transformer, ShouldInstrumentOptions, Options as TransformationOptions, + TransformResult, } from './types'; export {default as handlePotentialSyntaxError} from './enhanceUnexpectedTokenMessage'; diff --git a/packages/jest-transform/src/types.ts b/packages/jest-transform/src/types.ts index 064ebf380cb9..f6d557b61db2 100644 --- a/packages/jest-transform/src/types.ts +++ b/packages/jest-transform/src/types.ts @@ -10,8 +10,13 @@ import {Config} from '@jest/types'; export type ShouldInstrumentOptions = Pick< Config.GlobalConfig, - 'collectCoverage' | 'collectCoverageFrom' | 'collectCoverageOnlyFrom' -> & {changedFiles?: Set}; + | 'collectCoverage' + | 'collectCoverageFrom' + | 'collectCoverageOnlyFrom' + | 'coverageProvider' +> & { + changedFiles?: Set; +}; export type Options = ShouldInstrumentOptions & Partial<{ @@ -35,6 +40,7 @@ export type TransformedSource = { export type TransformResult = { code: string; + originalCode: string; mapCoverage: boolean; sourceMapPath: string | null; }; diff --git a/packages/jest-types/src/Config.ts b/packages/jest-types/src/Config.ts index aca89876a7c1..a69546d8b271 100644 --- a/packages/jest-types/src/Config.ts +++ b/packages/jest-types/src/Config.ts @@ -9,6 +9,8 @@ import {Arguments} from 'yargs'; import {ReportOptions} from 'istanbul-reports'; import chalk = require('chalk'); +type CoverageProvider = 'babel' | 'v8'; + export type Path = string; export type Glob = string; @@ -38,6 +40,7 @@ export type DefaultOptions = { collectCoverage: boolean; coveragePathIgnorePatterns: Array; coverageReporters: Array; + coverageProvider: CoverageProvider; errorOnDeprecated: boolean; expand: boolean; forceCoverageMatch: Array; @@ -107,6 +110,7 @@ export type InitialOptions = Partial<{ }; coverageDirectory: string; coveragePathIgnorePatterns: Array; + coverageProvider: CoverageProvider; coverageReporters: Array; coverageThreshold: { global: { @@ -236,6 +240,7 @@ export type GlobalConfig = { }; coverageDirectory: string; coveragePathIgnorePatterns?: Array; + coverageProvider: CoverageProvider; coverageReporters: Array; coverageThreshold?: CoverageThreshold; detectLeaks: boolean; diff --git a/yarn.lock b/yarn.lock index 6c519f91318b..0639a766001e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -941,6 +941,11 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + "@cnakazawa/watch@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.3.tgz#099139eaec7ebf07a27c1786a3ff64f39464d2ef" @@ -1944,7 +1949,7 @@ dependencies: "@types/ci-info" "*" -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" integrity sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg== @@ -4009,6 +4014,11 @@ collapse-white-space@^1.0.2: resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.5.tgz#c2495b699ab1ed380d29a1091e01063e75dbbe3a" integrity sha512-703bOOmytCYAX9cXYqoikYIx6twmFCXsnzRQheBcTG3nzKYBR4P/+wkYeH+Mvj7qUz8zZDtdyzbxfnEi/kYzRQ== +collect-v8-coverage@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.0.tgz#150ee634ac3650b71d9c985eb7f608942334feb1" + integrity sha512-VKIhJgvk8E1W28m5avZ2Gv2Ruv5YiF56ug2oclvaG9md69BuZImMG2sk9g7QNKLUbtYAKQjXjYxbYZVUlMMKmQ== + collection-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" @@ -4340,7 +4350,7 @@ conventional-recommended-bump@^5.0.0: meow "^4.0.0" q "^1.5.1" -convert-source-map@^1.4.0, convert-source-map@^1.7.0: +convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== @@ -8212,10 +8222,10 @@ istanbul-lib-source-maps@^4.0.0-alpha.4: istanbul-lib-coverage "^3.0.0-alpha.1" source-map "^0.6.1" -istanbul-reports@^3.0.0-alpha.4: - version "3.0.0-alpha.4" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.0-alpha.4.tgz#f703c04cc76b27e89b88c4fa74efec7865582e73" - integrity sha512-TPIUdttWC1oUvTL163ZtS0FqTlYaaAQBQdlVpF9Enu+w6oPOUR0p//WRdRyT/hbDG83PoQoSyatYzV6FrICDKg== +istanbul-reports@^3.0.0-alpha.5: + version "3.0.0-alpha.5" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.0-alpha.5.tgz#e8eb61c0bfdc46cbac5857482c6fa40481c5068b" + integrity sha512-m70NY8mnytN+e3C/UN8xVZ3OXcWBT+yVtupNdYy7X/nCiSHV7O4ImojikbQ+EgqvKO6JaG+Qowk9lBq9j2pjbA== dependencies: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0-alpha.1" @@ -14140,6 +14150,15 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== +v8-to-istanbul@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-4.0.1.tgz#d6a2a3823b8ff49bdf2167ff2a45d82dff81d02f" + integrity sha512-x0yZvZAkjJwdD3fPiJzYP37aod0ati4LlmD2RmpKjqewjKAov/u/ytZ8ViIZb07cN4cePKzl9ijiUi7C1LQ8hQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^1.6.0" + source-map "^0.7.3" + validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"