diff --git a/dangerfile.js b/dangerfile.js index e6f169cbb479..49d9a673e478 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -51,8 +51,9 @@ const raiseIssueAboutPaths = ( }; const newJsFiles = danger.git.created_files.filter(path => path.endsWith('js')); -const isNotInTestFiles = path => !(includes(path, '__tests__') - || includes(path, '__mocks__')); +const isSourceFile = path => + includes(path, '/src/') && + !includes(path, '__tests__'); // New JS files should have the FB copyright header + flow const facebookLicenseHeaderComponents = [ @@ -80,7 +81,7 @@ if (noFBCopyrightFiles.length > 0) { // Ensure the majority of all files use Flow // Does not run for test files, and also offers a warning not an error. const noFlowFiles = newJsFiles - .filter(isNotInTestFiles) + .filter(isSourceFile) .filter(filepath => { const content = fs.readFileSync(filepath).toString(); return !includes(content, '@flow'); diff --git a/docs/Configuration.md b/docs/Configuration.md index 6755ecd34690..b79928832c94 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -150,6 +150,23 @@ For example, the following would create a global `__DEV__` variable set to `true Note that, if you specify a global reference value (like an object or array) here, and some code mutates that value in the midst of running a test, that mutation will *not* be persisted across test runs for other test files. +### `mapCoverage` [boolean] + +##### available in Jest **20.0.0+** + +Default: `false` + +If you have [transformers](#transform-object-string-string) configured that emit source maps, Jest will use them to try and map code coverage against the original source code when writing [reports](#coveragereporters-array-string) and checking [thresholds](#coveragethreshold-object). This is done on a best-effort basis as some compile-to-JavaScript languages may provide more accurate source maps than others. This can also be resource-intensive. If Jest is taking a long time to calculate coverage at the end of a test run, try setting this option to `false`. + +Both inline source maps and source maps returned directly from a transformer are supported. Source map URLs are not supported because Jest may not be able to locate them. To return source maps from a transformer, the `process` function can return an object like the following. The `map` property may either be the source map object, or the source map object as a JSON string. + +```js +return { + code: 'the code', + map: 'the source map', +}; +``` + ### `moduleFileExtensions` [array] Default: `["js", "json", "jsx", "node"]` diff --git a/integration_tests/__tests__/__snapshots__/coverage-remapping-test.js.snap b/integration_tests/__tests__/__snapshots__/coverage-remapping-test.js.snap new file mode 100644 index 000000000000..00cebd87ecb8 --- /dev/null +++ b/integration_tests/__tests__/__snapshots__/coverage-remapping-test.js.snap @@ -0,0 +1,348 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`maps code coverage against original source 1`] = ` +Object { + "covered.ts": Object { + "b": Object { + "0": Array [ + 1, + 0, + ], + "1": Array [ + 1, + 0, + ], + "2": Array [ + 1, + 0, + 0, + ], + "3": Array [ + 1, + 0, + ], + }, + "branchMap": Object { + "0": Object { + "loc": Object { + "end": Object { + "column": 9, + "line": 5, + }, + "start": Object { + "column": 8, + "line": 5, + }, + }, + "locations": Array [ + Object { + "end": Object { + "column": 9, + "line": 5, + }, + "start": Object { + "column": 8, + "line": 5, + }, + }, + Object { + "end": Object { + "column": 9, + "line": 6, + }, + "start": Object { + "column": 8, + "line": 6, + }, + }, + ], + "type": "cond-expr", + }, + "1": Object { + "loc": Object { + "end": Object { + "column": 37, + "line": 7, + }, + "start": Object { + "column": 36, + "line": 7, + }, + }, + "locations": Array [ + Object { + "end": Object { + "column": 37, + "line": 7, + }, + "start": Object { + "column": 36, + "line": 7, + }, + }, + Object { + "end": Object { + "column": 41, + "line": 7, + }, + "start": Object { + "column": 40, + "line": 7, + }, + }, + ], + "type": "cond-expr", + }, + "2": Object { + "loc": Object { + "end": Object { + "column": 33, + "line": 8, + }, + "start": Object { + "column": 29, + "line": 8, + }, + }, + "locations": Array [ + Object { + "end": Object { + "column": 33, + "line": 8, + }, + "start": Object { + "column": 29, + "line": 8, + }, + }, + Object { + "end": Object { + "column": 41, + "line": 8, + }, + "start": Object { + "column": 37, + "line": 8, + }, + }, + Object { + "end": Object { + "column": 50, + "line": 8, + }, + "start": Object { + "column": 45, + "line": 8, + }, + }, + ], + "type": "binary-expr", + }, + "3": Object { + "loc": Object { + "end": Object { + "column": 42, + "line": 9, + }, + "start": Object { + "column": 32, + "line": 9, + }, + }, + "locations": Array [ + Object { + "end": Object { + "column": 42, + "line": 9, + }, + "start": Object { + "column": 32, + "line": 9, + }, + }, + Object { + "end": Object { + "column": 55, + "line": 9, + }, + "start": Object { + "column": 45, + "line": 9, + }, + }, + ], + "type": "cond-expr", + }, + }, + "f": Object { + "0": 1, + "1": 0, + "2": 0, + }, + "fnMap": Object { + "0": Object { + "decl": Object { + "end": Object { + "column": 28, + "line": 3, + }, + "start": Object { + "column": 9, + "line": 3, + }, + }, + "loc": Object { + "end": Object { + "column": 1, + "line": 12, + }, + "start": Object { + "column": 49, + "line": 3, + }, + }, + "name": "difference", + }, + "1": Object { + "decl": Object { + "end": Object { + "column": 37, + "line": 9, + }, + "start": Object { + "column": 32, + "line": 9, + }, + }, + "loc": Object { + "end": Object { + "column": 42, + "line": 9, + }, + "start": Object { + "column": 32, + "line": 9, + }, + }, + "name": "(anonymous_1)", + }, + "2": Object { + "decl": Object { + "end": Object { + "column": 50, + "line": 9, + }, + "start": Object { + "column": 45, + "line": 9, + }, + }, + "loc": Object { + "end": Object { + "column": 55, + "line": 9, + }, + "start": Object { + "column": 45, + "line": 9, + }, + }, + "name": "(anonymous_2)", + }, + }, + "path": "covered.ts", + "s": Object { + "0": 1, + "1": 1, + "2": 1, + "3": 1, + "4": 1, + "5": 0, + "6": 0, + "7": 1, + }, + "statementMap": Object { + "0": Object { + "end": Object { + "column": 1, + "line": 12, + }, + "start": Object { + "column": 0, + "line": 3, + }, + }, + "1": Object { + "end": Object { + "column": 9, + "line": 6, + }, + "start": Object { + "column": 29, + "line": 4, + }, + }, + "2": Object { + "end": Object { + "column": 41, + "line": 7, + }, + "start": Object { + "column": 29, + "line": 7, + }, + }, + "3": Object { + "end": Object { + "column": 50, + "line": 8, + }, + "start": Object { + "column": 29, + "line": 8, + }, + }, + "4": Object { + "end": Object { + "column": 55, + "line": 9, + }, + "start": Object { + "column": 25, + "line": 9, + }, + }, + "5": Object { + "end": Object { + "column": 42, + "line": 9, + }, + "start": Object { + "column": 38, + "line": 9, + }, + }, + "6": Object { + "end": Object { + "column": 55, + "line": 9, + }, + "start": Object { + "column": 51, + "line": 9, + }, + }, + "7": Object { + "end": Object { + "column": 17, + "line": 11, + }, + "start": Object { + "column": 4, + "line": 11, + }, + }, + }, + }, +} +`; diff --git a/integration_tests/__tests__/coverage-remapping-test.js b/integration_tests/__tests__/coverage-remapping-test.js new file mode 100644 index 000000000000..cce153fe3618 --- /dev/null +++ b/integration_tests/__tests__/coverage-remapping-test.js @@ -0,0 +1,37 @@ +/** + * 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 {readFileSync} = require('fs'); +const {run} = require('../utils'); +const path = require('path'); +const runJest = require('../runJest'); +const skipOnWindows = require('skipOnWindows'); + +skipOnWindows.suite(); + +it('maps code coverage against original source', () => { + const dir = path.resolve(__dirname, '../coverage-remapping'); + run('npm install', dir); + runJest(dir, ['--coverage', '--mapCoverage', '--no-cache']); + + const coverageMapFile = path.join( + __dirname, + '../coverage-remapping/coverage/coverage-final.json' + ); + const coverageMap = JSON.parse(readFileSync(coverageMapFile, 'utf-8')); + + // reduce absolute paths embedded in the coverage map to just filenames + Object.keys(coverageMap).forEach(filename => { + coverageMap[filename].path = path.basename(coverageMap[filename].path); + coverageMap[path.basename(filename)] = coverageMap[filename]; + delete coverageMap[filename]; + }); + expect(coverageMap).toMatchSnapshot(); +}); diff --git a/integration_tests/coverage-remapping/__tests__/covered-test.ts b/integration_tests/coverage-remapping/__tests__/covered-test.ts new file mode 100644 index 000000000000..a9f2f7c525ef --- /dev/null +++ b/integration_tests/coverage-remapping/__tests__/covered-test.ts @@ -0,0 +1,6 @@ +// Copyright 2004-present Facebook. All Rights Reserved. +const difference = require('../covered.ts'); + +it('subtracts correctly', () => { + expect(difference(3, 2)).toBe(1); +}); diff --git a/integration_tests/coverage-remapping/covered.ts b/integration_tests/coverage-remapping/covered.ts new file mode 100644 index 000000000000..df5496159f87 --- /dev/null +++ b/integration_tests/coverage-remapping/covered.ts @@ -0,0 +1,12 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +export = function difference(a: number, b: number): number { + const branch1: boolean = true + ? 1 + : 0; + const branch2: boolean = true ? 1 : 0; + const branch3: boolean = true || true || false; + const fn: Function = true ? () => null : () => null; + + return a - b; +} diff --git a/integration_tests/coverage-remapping/package.json b/integration_tests/coverage-remapping/package.json new file mode 100644 index 000000000000..ee9d5db999dc --- /dev/null +++ b/integration_tests/coverage-remapping/package.json @@ -0,0 +1,18 @@ +{ + "jest": { + "rootDir": "./", + "transform": { + "^.+\\.(ts|js)$": "/typescript-preprocessor.js" + }, + "testRegex": "/__tests__/.*\\.(ts|tsx|js)$", + "testEnvironment": "node", + "moduleFileExtensions": [ + "ts", + "tsx", + "js" + ] + }, + "dependencies": { + "typescript": "^1.8.10" + } +} diff --git a/integration_tests/coverage-remapping/typescript-preprocessor.js b/integration_tests/coverage-remapping/typescript-preprocessor.js new file mode 100644 index 000000000000..019e8f7a1e1b --- /dev/null +++ b/integration_tests/coverage-remapping/typescript-preprocessor.js @@ -0,0 +1,33 @@ +/** + * 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 tsc = require('typescript'); + +module.exports = { + process(src, path) { + if (path.endsWith('.ts') || path.endsWith('.tsx')) { + const result = tsc.transpileModule( + src, + { + compilerOptions: { + module: tsc.ModuleKind.CommonJS, + sourceMap: true, + }, + fileName: path, + } + ); + return { + code: result.outputText, + map: JSON.parse(result.sourceMapText), + }; + } + return src; + }, +}; diff --git a/packages/jest-cli/package.json b/packages/jest-cli/package.json index c52d7c1deca6..1b44424e87cb 100644 --- a/packages/jest-cli/package.json +++ b/packages/jest-cli/package.json @@ -12,6 +12,7 @@ "istanbul-api": "^1.1.0-alpha.1", "istanbul-lib-coverage": "^1.0.0", "istanbul-lib-instrument": "^1.1.1", + "istanbul-lib-source-maps": "^1.1.0", "jest-changed-files": "^19.0.2", "jest-config": "^19.0.2", "jest-environment-jsdom": "^19.0.2", diff --git a/packages/jest-cli/src/TestRunner.js b/packages/jest-cli/src/TestRunner.js index acbaadbaf42d..0c5d275a8102 100644 --- a/packages/jest-cli/src/TestRunner.js +++ b/packages/jest-cli/src/TestRunner.js @@ -517,6 +517,7 @@ const buildFailureTestResult = ( unmatched: 0, updated: 0, }, + sourceMaps: {}, testExecError: err, testFilePath: testPath, testResults: [], diff --git a/packages/jest-cli/src/__tests__/generateEmptyCoverage-test.js b/packages/jest-cli/src/__tests__/generateEmptyCoverage-test.js index 6b5b7a22b8df..c5f24186100c 100644 --- a/packages/jest-cli/src/__tests__/generateEmptyCoverage-test.js +++ b/packages/jest-cli/src/__tests__/generateEmptyCoverage-test.js @@ -39,5 +39,5 @@ it('generates an empty coverage object for a file without running it', () => { baseCacheDir: os.tmpdir(), cacheDirectory: os.tmpdir(), rootDir: os.tmpdir(), - })).toMatchSnapshot(); + }).coverage).toMatchSnapshot(); }); diff --git a/packages/jest-cli/src/cli/args.js b/packages/jest-cli/src/cli/args.js index d721f7928223..ae79d7ba7833 100644 --- a/packages/jest-cli/src/cli/args.js +++ b/packages/jest-cli/src/cli/args.js @@ -152,6 +152,12 @@ const options = { 'leaks. Use together with `--runInBand` and `--expose-gc` in node.', type: 'boolean', }, + mapCoverage: { + description: + 'Maps code coverage reports against original source code when ' + + 'transformers supply source maps.', + type: 'boolean', + }, maxWorkers: { alias: 'w', description: diff --git a/packages/jest-cli/src/generateEmptyCoverage.js b/packages/jest-cli/src/generateEmptyCoverage.js index 6eade08c24a3..5ea23983e670 100644 --- a/packages/jest-cli/src/generateEmptyCoverage.js +++ b/packages/jest-cli/src/generateEmptyCoverage.js @@ -20,10 +20,13 @@ module.exports = function(source: string, filename: Path, config: Config) { if (shouldInstrument(filename, config)) { // Transform file without instrumentation first, to make sure produced // source code is ES6 (no flowtypes etc.) and can be instrumented - source = transformSource(filename, config, source, false); + const transformResult = transformSource(filename, config, source, false); const instrumenter = IstanbulInstrument.createInstrumenter(); - instrumenter.instrumentSync(source, filename); - return instrumenter.fileCoverage; + instrumenter.instrumentSync(transformResult.code, filename); + return { + coverage: instrumenter.fileCoverage, + sourceMapPath: transformResult.sourceMapPath, + }; } else { return null; } diff --git a/packages/jest-cli/src/reporters/CoverageReporter.js b/packages/jest-cli/src/reporters/CoverageReporter.js index b1487b3dc4da..db61612b8bb0 100644 --- a/packages/jest-cli/src/reporters/CoverageReporter.js +++ b/packages/jest-cli/src/reporters/CoverageReporter.js @@ -22,6 +22,7 @@ const fs = require('fs'); const generateEmptyCoverage = require('../generateEmptyCoverage'); const isCI = require('is-ci'); const istanbulCoverage = require('istanbul-lib-coverage'); +const libSourceMaps = require('istanbul-lib-source-maps'); const FAIL_COLOR = chalk.bold.red; const RUNNING_TEST_COLOR = chalk.bold.dim; @@ -30,10 +31,12 @@ const isInteractive = process.stdout.isTTY && !isCI; class CoverageReporter extends BaseReporter { _coverageMap: CoverageMap; + _sourceMapStore: any; constructor() { super(); this._coverageMap = istanbulCoverage.createCoverageMap({}); + this._sourceMapStore = libSourceMaps.createSourceMapStore(); } onTestResult( @@ -45,6 +48,13 @@ class CoverageReporter extends BaseReporter { this._coverageMap.merge(testResult.coverage); // Remove coverage data to free up some memory. delete testResult.coverage; + + Object.keys(testResult.sourceMaps).forEach(sourcePath => { + this._sourceMapStore.registerURL( + sourcePath, + testResult.sourceMaps[sourcePath] + ); + }); } } @@ -54,6 +64,12 @@ class CoverageReporter extends BaseReporter { runnerContext: RunnerContext, ) { this._addUntestedFiles(config, runnerContext); + let map = this._coverageMap; + let sourceFinder: Object; + if (config.mapCoverage) { + ({map, sourceFinder} = this._sourceMapStore.transformCoverage(map)); + } + const reporter = createReporter(); try { if (config.coverageDirectory) { @@ -70,8 +86,8 @@ class CoverageReporter extends BaseReporter { } reporter.addAll(coverageReporters); - reporter.write(this._coverageMap); - aggregatedResults.coverageMap = this._coverageMap; + reporter.write(map, sourceFinder && {sourceFinder}); + aggregatedResults.coverageMap = map; } catch (e) { console.error(chalk.red(` Failed to write coverage reports: @@ -80,7 +96,7 @@ class CoverageReporter extends BaseReporter { `)); } - this._checkThreshold(config); + this._checkThreshold(map, config); } _addUntestedFiles(config: Config, runnerContext: RunnerContext) { @@ -99,9 +115,15 @@ class CoverageReporter extends BaseReporter { if (!this._coverageMap.data[filename]) { try { const source = fs.readFileSync(filename).toString(); - const coverage = generateEmptyCoverage(source, filename, config); - if (coverage) { - this._coverageMap.addFileCoverage(coverage); + const result = generateEmptyCoverage(source, filename, config); + if (result) { + this._coverageMap.addFileCoverage(result.coverage); + if (result.sourceMapPath) { + this._sourceMapStore.registerURL( + filename, + result.sourceMapPath + ); + } } } catch (e) { console.error(chalk.red(` @@ -118,9 +140,9 @@ class CoverageReporter extends BaseReporter { } } - _checkThreshold(config: Config) { + _checkThreshold(map: CoverageMap, config: Config) { if (config.coverageThreshold) { - const results = this._coverageMap.getCoverageSummary().toJSON(); + const results = map.getCoverageSummary().toJSON(); function check(name, thresholds, actuals) { return [ diff --git a/packages/jest-cli/src/reporters/__tests__/CoverageReporter-test.js b/packages/jest-cli/src/reporters/__tests__/CoverageReporter-test.js index 9c6eaf68a473..2bcd9db1c393 100644 --- a/packages/jest-cli/src/reporters/__tests__/CoverageReporter-test.js +++ b/packages/jest-cli/src/reporters/__tests__/CoverageReporter-test.js @@ -12,9 +12,11 @@ jest .mock('fs') .mock('istanbul-lib-coverage') + .mock('istanbul-lib-source-maps') .mock('istanbul-api'); let libCoverage; +let libSourceMaps; let CoverageReporter; let istanbulApi; @@ -27,6 +29,7 @@ beforeEach(() => { CoverageReporter = require('../CoverageReporter'); libCoverage = require('istanbul-lib-coverage'); + libSourceMaps = require('istanbul-lib-source-maps'); }); describe('onRunComplete', () => { @@ -66,6 +69,14 @@ describe('onRunComplete', () => { }; }); + libSourceMaps.createSourceMapStore = jest.fn(() => { + return { + transformCoverage(map) { + return {map}; + }, + }; + }); + testReporter = new CoverageReporter(); testReporter.log = jest.fn(); }); diff --git a/packages/jest-cli/src/runTest.js b/packages/jest-cli/src/runTest.js index 19027e4f679a..2589cf4bd12e 100644 --- a/packages/jest-cli/src/runTest.js +++ b/packages/jest-cli/src/runTest.js @@ -59,6 +59,7 @@ function runTest(path: Path, config: Config, resolver: Resolver) { result.perfStats = {end: Date.now(), start}; result.testFilePath = path; result.coverage = runtime.getAllCoverageInfo(); + result.sourceMaps = runtime.getSourceMapInfo(); result.console = testConsole.getBuffer(); result.skipped = testCount === result.numPendingTests; return result; diff --git a/packages/jest-config/src/defaults.js b/packages/jest-config/src/defaults.js index faea8a93dca0..2b38275c529c 100644 --- a/packages/jest-config/src/defaults.js +++ b/packages/jest-config/src/defaults.js @@ -32,6 +32,7 @@ module.exports = ({ haste: { providesModuleNodeModules: [], }, + mapCoverage: false, moduleDirectories: ['node_modules'], moduleFileExtensions: ['js', 'json', 'jsx', 'node'], moduleNameMapper: {}, diff --git a/packages/jest-config/src/normalize.js b/packages/jest-config/src/normalize.js index cf591f3e3349..d5762ec62c6c 100644 --- a/packages/jest-config/src/normalize.js +++ b/packages/jest-config/src/normalize.js @@ -344,6 +344,7 @@ function normalize(config: InitialConfig, argv: Object = {}) { case 'globals': case 'logHeapUsage': case 'logTransformErrors': + case 'mapCoverage': case 'moduleDirectories': case 'moduleFileExtensions': case 'moduleLoader': diff --git a/packages/jest-config/src/setFromArgv.js b/packages/jest-config/src/setFromArgv.js index c03e7a521919..0419a26541b4 100644 --- a/packages/jest-config/src/setFromArgv.js +++ b/packages/jest-config/src/setFromArgv.js @@ -17,6 +17,10 @@ function setFromArgv(config, argv) { config.coverageDirectory = argv.coverageDirectory; } + if (argv.mapCoverage) { + config.mapCoverage = true; + } + if (argv.verbose) { config.verbose = argv.verbose; } diff --git a/packages/jest-config/src/validConfig.js b/packages/jest-config/src/validConfig.js index 27632e2c9205..e6b984c9512e 100644 --- a/packages/jest-config/src/validConfig.js +++ b/packages/jest-config/src/validConfig.js @@ -45,6 +45,7 @@ module.exports = ({ }, logHeapUsage: true, logTransformErrors: true, + mapCoverage: false, moduleDirectories: ['node_modules'], moduleFileExtensions: ['js', 'json', 'jsx', 'node'], moduleLoader: '', diff --git a/packages/jest-runtime/package.json b/packages/jest-runtime/package.json index 71d4a2655922..1e9b94a70f76 100644 --- a/packages/jest-runtime/package.json +++ b/packages/jest-runtime/package.json @@ -12,6 +12,7 @@ "babel-jest": "^19.0.0", "babel-plugin-istanbul": "^4.0.0", "chalk": "^1.1.3", + "convert-source-map": "^1.3.0", "graceful-fs": "^4.1.6", "jest-config": "^19.0.2", "jest-file-exists": "^19.0.0", diff --git a/packages/jest-runtime/src/__tests__/instrumentation-test.js b/packages/jest-runtime/src/__tests__/instrumentation-test.js index cb8f7b230c93..e7e59d21f6f3 100644 --- a/packages/jest-runtime/src/__tests__/instrumentation-test.js +++ b/packages/jest-runtime/src/__tests__/instrumentation-test.js @@ -28,7 +28,7 @@ it('instruments files', () => { collectCoverage: true, rootDir: '/', }; - const instrumented = transform(FILE_PATH_TO_INSTRUMENT, config); + const instrumented = transform(FILE_PATH_TO_INSTRUMENT, config).script; expect(instrumented instanceof vm.Script).toBe(true); // We can't really snapshot the resulting coverage, because it depends on // absolute path of the file, which will be different on different diff --git a/packages/jest-runtime/src/__tests__/transform-test.js b/packages/jest-runtime/src/__tests__/transform-test.js index 3b8c94dd065f..23663b9f5eda 100644 --- a/packages/jest-runtime/src/__tests__/transform-test.js +++ b/packages/jest-runtime/src/__tests__/transform-test.js @@ -14,6 +14,9 @@ const skipOnWindows = require('skipOnWindows'); jest .mock('graceful-fs') .mock('jest-file-exists') + .mock('jest-haste-map', () => ({ + getCacheFilePath: (cacheDir, baseDir, version) => cacheDir + baseDir, + })) .mock('jest-util', () => { const util = require.requireActual('jest-util'); util.createDirectory = jest.fn(); @@ -44,6 +47,17 @@ jest.mock( {virtual: true}, ); +jest.mock( + 'preprocessor-with-sourcemaps', + () => { + return { + getCacheKey: jest.fn((content, filename, configStr) => 'ab'), + process: jest.fn(), + }; + }, + {virtual: true}, +); + jest.mock( 'css-preprocessor', () => { @@ -142,7 +156,7 @@ describe('transform', () => { it('transforms a file properly', () => { config.collectCoverage = true; - const response = transform('/fruits/banana.js', config); + const response = transform('/fruits/banana.js', config).script; expect(response instanceof vm.Script).toBe(true); expect(vm.Script.mock.calls[0][0]).toMatchSnapshot(); @@ -152,7 +166,7 @@ describe('transform', () => { expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); // in-memory cache - const response2 = transform('/fruits/banana.js', config); + const response2 = transform('/fruits/banana.js', config).script; expect(response2).toBe(response); transform('/fruits/kiwi.js', config); @@ -212,6 +226,81 @@ describe('transform', () => { expect(vm.Script.mock.calls[2][0]).toMatchSnapshot(); }); + it('writes source map if preprocessor supplies it', () => { + config = Object.assign(config, { + collectCoverage: true, + mapCoverage: true, + transform: [['^.+\\.js$', 'preprocessor-with-sourcemaps']], + }); + + const map = { + mappings: ';AAAA', + version: 3, + }; + + require('preprocessor-with-sourcemaps').process.mockReturnValue({ + code: 'content', + map, + }); + + const result = transform('/fruits/banana.js', config); + expect(result.sourceMapPath).toEqual(expect.any(String)); + expect(fs.writeFileSync).toBeCalledWith( + result.sourceMapPath, + JSON.stringify(map), + 'utf8', + ); + }); + + it('writes source map if preprocessor inlines it', () => { + config = Object.assign(config, { + collectCoverage: true, + mapCoverage: true, + transform: [['^.+\\.js$', 'preprocessor-with-sourcemaps']], + }); + + const sourceMap = JSON.stringify({ + mappings: 'AAAA,IAAM,CAAC,GAAW,CAAC,CAAC', + version: 3, + }); + + const content = 'var x = 1;\n' + + '//# sourceMappingURL=data:application/json;base64,' + + new Buffer(sourceMap).toString('base64'); + + require('preprocessor-with-sourcemaps').process.mockReturnValue(content); + + const result = transform('/fruits/banana.js', config); + expect(result.sourceMapPath).toEqual(expect.any(String)); + expect(fs.writeFileSync).toBeCalledWith( + result.sourceMapPath, + sourceMap, + 'utf8', + ); + }); + + it('does not write source map if mapCoverage config option is false', () => { + config = Object.assign(config, { + collectCoverage: true, + mapCoverage: false, + transform: [['^.+\\.js$', 'preprocessor-with-sourcemaps']], + }); + + const map = { + mappings: ';AAAA', + version: 3, + }; + + require('preprocessor-with-sourcemaps').process.mockReturnValue({ + code: 'content', + map, + }); + + const result = transform('/fruits/banana.js', config); + expect(result.sourceMapPath).toBeFalsy(); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + }); + it('reads values from the cache', () => { if (skipOnWindows.test()) { return; diff --git a/packages/jest-runtime/src/index.js b/packages/jest-runtime/src/index.js index a592e31ca696..427391cbe3ea 100644 --- a/packages/jest-runtime/src/index.js +++ b/packages/jest-runtime/src/index.js @@ -87,6 +87,7 @@ class Runtime { _shouldAutoMock: boolean; _shouldMockModuleCache: BooleanObject; _shouldUnmockTransitiveDependenciesCache: BooleanObject; + _sourceMapRegistry: {[key: string]: string}; _transitiveShouldMock: BooleanObject; _unmockList: ?RegExp; _virtualMocks: BooleanObject; @@ -95,6 +96,7 @@ class Runtime { this._moduleRegistry = Object.create(null); this._internalModuleRegistry = Object.create(null); this._mockRegistry = Object.create(null); + this._sourceMapRegistry = Object.create(null); this._config = config; this._environment = environment; this._resolver = resolver; @@ -388,6 +390,15 @@ class Runtime { return this._environment.global.__coverage__; } + getSourceMapInfo() { + return Object.keys(this._sourceMapRegistry).reduce((result, sourcePath) => { + if (fs.existsSync(this._sourceMapRegistry[sourcePath])) { + result[sourcePath] = this._sourceMapRegistry[sourcePath]; + } + return result; + }, {}); + } + setMock( from: string, moduleName: string, @@ -435,9 +446,17 @@ class Runtime { localModule.paths = this._resolver.getModulePaths(dirname); localModule.require = this._createRequireImplementation(filename, options); - const script = transform(filename, this._config, {isInternalModule}); + const transformedFile = transform( + filename, + this._config, + {isInternalModule} + ); + + if (transformedFile.sourceMapPath) { + this._sourceMapRegistry[filename] = transformedFile.sourceMapPath; + } - const wrapper = this._environment.runScript(script)[ + const wrapper = this._environment.runScript(transformedFile.script)[ transform.EVAL_RESULT_VARIABLE ]; wrapper.call( diff --git a/packages/jest-runtime/src/transform.js b/packages/jest-runtime/src/transform.js index 8e1410ad68b1..2928660e5d33 100644 --- a/packages/jest-runtime/src/transform.js +++ b/packages/jest-runtime/src/transform.js @@ -10,8 +10,11 @@ 'use strict'; import type {Config, Path} from 'types/Config'; -import type {Transformer} from 'types/Transform'; - +import type { + Transformer, + TransformedSource, + BuiltTransformResult, +} from 'types/Transform'; const createDirectory = require('jest-util').createDirectory; const crypto = require('crypto'); const fileExists = require('jest-file-exists'); @@ -30,7 +33,7 @@ type Options = {| const EVAL_RESULT_VARIABLE = 'Object.'; -const cache: Map = new Map(); +const cache: Map = new Map(); const configToJsonMap = new Map(); // Cache regular expressions to test whether the file needs to be preprocessed const ignoreCache: WeakMap = new WeakMap(); @@ -254,6 +257,7 @@ const instrumentFile = ( { cwd: config.rootDir, // files outside `cwd` will not be instrumented exclude: [], + useInlineSourceMaps: false, }, ], ], @@ -266,35 +270,75 @@ const transformSource = ( config: Config, content: string, instrument: boolean, -): string => { +) => { const transform = getTransformer(filename, config); const cacheFilePath = getFileCachePath(filename, config, content, instrument); + let sourceMapPath = cacheFilePath + '.map'; // Ignore cache if `config.cache` is set (--no-cache) - let result = config.cache ? readCacheFile(filename, cacheFilePath) : null; + let code = config.cache ? readCacheFile(filename, cacheFilePath) : null; - if (result) { - return result; + if (code) { + return { + code, + sourceMapPath, + }; } - result = content; + let transformed: TransformedSource = { + code: content, + map: null, + }; if (transform && shouldTransform(filename, config)) { - result = transform.process(result, filename, config, { + const processed = transform.process(content, filename, config, { instrument, watch: config.watch, }); + + if (typeof processed === 'string') { + transformed.code = processed; + } else { + transformed = processed; + } + } + + if (config.mapCoverage) { + if (!transformed.map) { + const convert = require('convert-source-map'); + const inlineSourceMap = convert.fromSource(transformed.code); + if (inlineSourceMap) { + transformed.map = inlineSourceMap.toJSON(); + } + } + } else { + transformed.map = null; } // That means that the transform has a custom instrumentation // logic and will handle it based on `config.collectCoverage` option - const transformWillInstrument = transform && transform.canInstrument; + const transformDidInstrument = transform && transform.canInstrument; - if (!transformWillInstrument && instrument) { - result = instrumentFile(result, filename, config); + if (!transformDidInstrument && instrument) { + code = instrumentFile(transformed.code, filename, config); + } else { + code = transformed.code; + } + + if (instrument && transformed.map && config.mapCoverage) { + const sourceMapContent = typeof transformed.map === 'string' + ? transformed.map + : JSON.stringify(transformed.map); + writeCacheFile(sourceMapPath, sourceMapContent); + } else { + sourceMapPath = null; } - writeCacheFile(cacheFilePath, result); - return result; + writeCacheFile(cacheFilePath, code); + + return { + code, + sourceMapPath, + }; }; const transformAndBuildScript = ( @@ -302,22 +346,33 @@ const transformAndBuildScript = ( config: Config, options: ?Options, instrument: boolean, -): vm.Script => { +): BuiltTransformResult => { const isInternalModule = !!(options && options.isInternalModule); const content = stripShebang(fs.readFileSync(filename, 'utf8')); - let wrappedResult; + let wrappedCode: string; + let sourceMapPath: ?string = null; const willTransform = !isInternalModule && (shouldTransform(filename, config) || instrument); try { if (willTransform) { - wrappedResult = - wrap(transformSource(filename, config, content, instrument)); + const transformedSource = transformSource( + filename, + config, + content, + instrument + ); + + wrappedCode = wrap(transformedSource.code); + sourceMapPath = transformedSource.sourceMapPath; } else { - wrappedResult = wrap(content); + wrappedCode = wrap(content); } - return new vm.Script(wrappedResult, {displayErrors: true, filename}); + return { + script: new vm.Script(wrappedCode, {displayErrors: true, filename}), + sourceMapPath, + }; } catch (e) { if (e.codeFrame) { e.stack = e.codeFrame; @@ -329,7 +384,7 @@ const transformAndBuildScript = ( `TRANSFORM: ${willTransform.toString()}\n` + `INSTRUMENT: ${instrument.toString()}\n` + `SOURCE:\n` + - String(wrappedResult), + String(wrappedCode), ); } @@ -341,16 +396,16 @@ module.exports = ( filename: Path, config: Config, options: Options, -): vm.Script => { +): BuiltTransformResult => { const instrument = shouldInstrument(filename, config); const scriptCacheKey = getScriptCacheKey(filename, config, instrument); - let script = cache.get(scriptCacheKey); - if (script) { - return script; + let result = cache.get(scriptCacheKey); + if (result) { + return result; } else { - script = transformAndBuildScript(filename, config, options, instrument); - cache.set(scriptCacheKey, script); - return script; + result = transformAndBuildScript(filename, config, options, instrument); + cache.set(scriptCacheKey, result); + return result; } }; diff --git a/types/Config.js b/types/Config.js index 05805bc3488f..f7dee74b88c8 100644 --- a/types/Config.js +++ b/types/Config.js @@ -32,6 +32,7 @@ export type DefaultConfig = {| expand: boolean, globals: ConfigGlobals, haste: HasteConfig, + mapCoverage: boolean, moduleDirectories: Array, moduleFileExtensions: Array, moduleNameMapper: {[key: string]: string}, @@ -74,6 +75,7 @@ export type Config = {| forceExit: boolean, globals: ConfigGlobals, haste: HasteConfig, + mapCoverage: boolean, logHeapUsage: boolean, logTransformErrors: ?boolean, moduleDirectories: Array, @@ -134,6 +136,7 @@ export type InitialConfig = {| haste?: HasteConfig, logHeapUsage?: boolean, logTransformErrors?: ?boolean, + mapCoverage?: boolean, moduleDirectories?: Array, moduleFileExtensions?: Array, moduleLoader?: Path, diff --git a/types/TestResult.js b/types/TestResult.js index d19e3dfa1b84..34fbfad28e45 100644 --- a/types/TestResult.js +++ b/types/TestResult.js @@ -11,12 +11,22 @@ import type {ConsoleBuffer} from './Console'; -export type Coverage = {| - coveredSpans: Array, - uncoveredSpans: Array, - sourceText: string, +export type RawFileCoverage = {| + path: string, + s: { [statementId: number]: number }, + b: { [branchId: number]: number }, + f: { [functionId: number]: number }, + l: { [lineId: number]: number }, + fnMap: { [functionId: number]: any }, + statementMap: { [statementId: number]: any }, + branchMap: { [branchId: number]: any }, + inputSourceMap?: Object, |}; +export type RawCoverage = { + [filePath: string]: RawFileCoverage, +}; + type FileCoverageTotal = {| total: number, covered: number, @@ -46,8 +56,8 @@ export type FileCoverage = {| export type CoverageMap = {| merge: (data: Object) => void, getCoverageSummary: () => FileCoverage, - data: Object, - addFileCoverage: (fileCoverage: Object) => void, + data: RawCoverage, + addFileCoverage: (fileCoverage: RawFileCoverage) => void, files: () => Array, fileCoverageFor: (file: string) => FileCoverage, |}; @@ -115,7 +125,7 @@ export type Suite = {| export type TestResult = {| console: ?ConsoleBuffer, - coverage?: Coverage, + coverage?: RawCoverage, memoryUsage?: Bytes, failureMessage: ?string, numFailingTests: number, @@ -134,6 +144,7 @@ export type TestResult = {| unmatched: number, updated: number, |}, + sourceMaps: {[sourcePath: string]: string}, testExecError?: SerializableError, testFilePath: string, testResults: Array, @@ -171,7 +182,7 @@ export type FormattedTestResults = { export type CodeCoverageReporter = any; export type CodeCoverageFormatter = ( - coverage: ?Coverage, + coverage: ?RawCoverage, reporter?: CodeCoverageReporter, ) => ?Object; diff --git a/types/Transform.js b/types/Transform.js index 9fa6ffdc9c45..219d313b12ea 100644 --- a/types/Transform.js +++ b/types/Transform.js @@ -10,6 +10,17 @@ 'use strict'; import type {Config, Path} from 'types/Config'; +import type {Script} from 'vm'; + +export type TransformedSource = {| + code: string, + map: ?Object | string, +|}; + +export type BuiltTransformResult = {| + script: Script, + sourceMapPath: ?string, +|}; export type TransformOptions = {| instrument: boolean, @@ -31,5 +42,5 @@ export type Transformer = {| sourcePath: Path, config: Config, options?: TransformOptions, - ) => string, + ) => string | TransformedSource, |};