From bea6547621a97a673f3632911027ba8119f9d004 Mon Sep 17 00:00:00 2001 From: cpojer Date: Wed, 26 Apr 2017 18:29:31 +0100 Subject: [PATCH] Refactor transform.js into a ScriptTransformer --- .../jest-cli/src/generateEmptyCoverage.js | 8 +- .../jest-runtime/src/ScriptTransformer.js | 399 ++++++++++++++++ .../__snapshots__/transform-test.js.snap | 14 +- .../src/__tests__/instrumentation-test.js | 6 +- .../src/__tests__/transform-test.js | 54 ++- packages/jest-runtime/src/index.js | 36 +- packages/jest-runtime/src/transform.js | 428 ------------------ types/Transform.js | 2 +- 8 files changed, 462 insertions(+), 485 deletions(-) create mode 100644 packages/jest-runtime/src/ScriptTransformer.js delete mode 100644 packages/jest-runtime/src/transform.js diff --git a/packages/jest-cli/src/generateEmptyCoverage.js b/packages/jest-cli/src/generateEmptyCoverage.js index 0b9488eff21f..99ab4a350203 100644 --- a/packages/jest-cli/src/generateEmptyCoverage.js +++ b/packages/jest-cli/src/generateEmptyCoverage.js @@ -14,7 +14,7 @@ import type {ProjectConfig, Path} from 'types/Config'; const IstanbulInstrument = require('istanbul-lib-instrument'); -const {transformSource, shouldInstrument} = require('jest-runtime'); +const {ScriptTransformer, shouldInstrument} = require('jest-runtime'); module.exports = function( source: string, @@ -24,7 +24,11 @@ module.exports = function( if (shouldInstrument(filename, config)) { // Transform file without instrumentation first, to make sure produced // source code is ES6 (no flowtypes etc.) and can be instrumented - const transformResult = transformSource(filename, config, source, false); + const transformResult = new ScriptTransformer(config).transformSource( + filename, + source, + false, + ); const instrumenter = IstanbulInstrument.createInstrumenter(); instrumenter.instrumentSync(transformResult.code, filename); return { diff --git a/packages/jest-runtime/src/ScriptTransformer.js b/packages/jest-runtime/src/ScriptTransformer.js new file mode 100644 index 000000000000..5827286d6e8f --- /dev/null +++ b/packages/jest-runtime/src/ScriptTransformer.js @@ -0,0 +1,399 @@ +/** + * 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 {Path, ProjectConfig} from 'types/Config'; +import type { + Transformer, + TransformedSource, + TransformResult, +} from 'types/Transform'; + +const createDirectory = require('jest-util').createDirectory; +const crypto = require('crypto'); +const fs = require('graceful-fs'); +const getCacheFilePath = require('jest-haste-map').getCacheFilePath; +const path = require('path'); +const shouldInstrument = require('./shouldInstrument'); +const stableStringify = require('json-stable-stringify'); +const vm = require('vm'); +const slash = require('slash'); + +const VERSION = require('../package.json').version; + +type Options = {| + isInternalModule?: boolean, +|}; + +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(); + +class ScriptTransformer { + static EVAL_RESULT_VARIABLE: string; + _config: ProjectConfig; + _transformCache: Map; + + constructor(config: ProjectConfig) { + this._config = config; + this._transformCache = new Map(); + } + + _getCacheKey(fileData: string, filename: Path, instrument: boolean): string { + if (!configToJsonMap.has(this._config)) { + // We only need this set of config options that can likely influence + // cached output instead of all config options. + configToJsonMap.set(this._config, stableStringify(this._config)); + } + const configString = configToJsonMap.get(this._config) || ''; + const transformer = this._getTransformer(filename, this._config); + + if (transformer && typeof transformer.getCacheKey === 'function') { + return transformer.getCacheKey(fileData, filename, configString, { + instrument, + }); + } else { + return crypto + .createHash('md5') + .update(fileData) + .update(configString) + .update(instrument ? 'instrument' : '') + .digest('hex'); + } + } + + _getFileCachePath( + filename: Path, + content: string, + instrument: boolean, + ): Path { + const baseCacheDir = getCacheFilePath( + this._config.cacheDirectory, + 'jest-transform-cache-' + this._config.name, + VERSION, + ); + const cacheKey = this._getCacheKey(content, filename, instrument); + // Create sub folders based on the cacheKey to avoid creating one + // directory with many files. + const cacheDir = path.join(baseCacheDir, cacheKey[0] + cacheKey[1]); + const cachePath = slash( + path.join( + cacheDir, + path.basename(filename, path.extname(filename)) + '_' + cacheKey, + ), + ); + createDirectory(cacheDir); + + return cachePath; + } + + _getTransformPath(filename: Path) { + for (let i = 0; i < this._config.transform.length; i++) { + if (new RegExp(this._config.transform[i][0]).test(filename)) { + return this._config.transform[i][1]; + } + } + return null; + } + + _getTransformer(filename: Path) { + let transform: ?Transformer; + if (!this._config.transform || !this._config.transform.length) { + return null; + } + + const transformPath = this._getTransformPath(filename); + if (transformPath) { + const transformer = this._transformCache.get(transformPath); + if (transformer != null) { + return transformer; + } + + // $FlowFixMe + transform = (require(transformPath): Transformer); + if (typeof transform.process !== 'function') { + throw new TypeError( + 'Jest: a transform must export a `process` function.', + ); + } + if (typeof transform.createTransformer === 'function') { + transform = transform.createTransformer(); + } + this._transformCache.set(transformPath, transform); + } + return transform; + } + + _instrumentFile(filename: Path, content: string): string { + // Keeping these requires inside this function reduces a single run + // time by 2sec if not running in `--coverage` mode + const babel = require('babel-core'); + const babelPluginIstanbul = require('babel-plugin-istanbul').default; + + return babel.transform(content, { + auxiliaryCommentBefore: ' istanbul ignore next ', + babelrc: false, + filename, + plugins: [ + [ + babelPluginIstanbul, + { + // files outside `cwd` will not be instrumented + cwd: this._config.rootDir, + exclude: [], + useInlineSourceMaps: false, + }, + ], + ], + retainLines: true, + }).code; + } + + transformSource(filename: Path, content: string, instrument: boolean) { + const transform = this._getTransformer(filename); + const cacheFilePath = this._getFileCachePath(filename, content, instrument); + let sourceMapPath = cacheFilePath + '.map'; + // Ignore cache if `config.cache` is set (--no-cache) + let code = this._config.cache + ? readCacheFile(filename, cacheFilePath) + : null; + + if (code) { + return { + code, + sourceMapPath, + }; + } + + let transformed: TransformedSource = { + code: content, + map: null, + }; + + if (transform && shouldTransform(filename, this._config)) { + const processed = transform.process(content, filename, this._config, { + instrument, + }); + + if (typeof processed === 'string') { + transformed.code = processed; + } else { + transformed = processed; + } + } + + if (this._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 transformDidInstrument = transform && transform.canInstrument; + + if (!transformDidInstrument && instrument) { + code = this._instrumentFile(filename, transformed.code); + } else { + code = transformed.code; + } + + if (instrument && transformed.map && this._config.mapCoverage) { + const sourceMapContent = typeof transformed.map === 'string' + ? transformed.map + : JSON.stringify(transformed.map); + writeCacheFile(sourceMapPath, sourceMapContent); + } else { + sourceMapPath = null; + } + + writeCacheFile(cacheFilePath, code); + + return { + code, + sourceMapPath, + }; + } + + _transformAndBuildScript( + filename: Path, + options: ?Options, + instrument: boolean, + fileSource?: string, + ): TransformResult { + const isInternalModule = !!(options && options.isInternalModule); + const content = stripShebang( + fileSource || fs.readFileSync(filename, 'utf8'), + ); + let wrappedCode: string; + let sourceMapPath: ?string = null; + const willTransform = + !isInternalModule && + (shouldTransform(filename, this._config) || instrument); + + try { + if (willTransform) { + const transformedSource = this.transformSource( + filename, + content, + instrument, + ); + + wrappedCode = wrap(transformedSource.code); + sourceMapPath = transformedSource.sourceMapPath; + } else { + wrappedCode = wrap(content); + } + + return { + script: new vm.Script(wrappedCode, {displayErrors: true, filename}), + sourceMapPath, + }; + } catch (e) { + if (e.codeFrame) { + e.stack = e.codeFrame; + } + + if (this._config.logTransformErrors) { + console.error( + `FILENAME: ${filename}\n` + + `TRANSFORM: ${willTransform.toString()}\n` + + `INSTRUMENT: ${instrument.toString()}\n` + + `SOURCE:\n` + + String(wrappedCode), + ); + } + + throw e; + } + } + + transform( + filename: Path, + options: Options, + fileSource?: string, + ): TransformResult { + const instrument = shouldInstrument(filename, this._config); + const scriptCacheKey = getScriptCacheKey( + filename, + this._config, + instrument, + ); + let result = cache.get(scriptCacheKey); + if (result) { + return result; + } else { + result = this._transformAndBuildScript( + filename, + options, + instrument, + fileSource, + ); + cache.set(scriptCacheKey, result); + return result; + } + } +} + +const removeFile = (path: Path) => { + try { + fs.unlinkSync(path); + } catch (e) {} +}; + +const stripShebang = content => { + // If the file data starts with a shebang remove it. Leaves the empty line + // to keep stack trace line numbers correct. + if (content.startsWith('#!')) { + return content.replace(/^#!.*/, ''); + } else { + return content; + } +}; + +const writeCacheFile = (cachePath: Path, fileData: string) => { + try { + fs.writeFileSync(cachePath, fileData, 'utf8'); + } catch (e) { + e.message = + 'jest: failed to cache transform results in: ' + + cachePath + + '\nFailure message: ' + + e.message; + removeFile(cachePath); + throw e; + } +}; + +const readCacheFile = (filename: Path, cachePath: Path): ?string => { + if (!fs.existsSync(cachePath)) { + return null; + } + + let fileData; + try { + fileData = fs.readFileSync(cachePath, 'utf8'); + } catch (e) { + e.message = 'jest: failed to read cache file: ' + cachePath; + removeFile(cachePath); + throw e; + } + + if (fileData == null) { + // We must have somehow created the file but failed to write to it, + // let's delete it and retry. + removeFile(cachePath); + } + return fileData; +}; + +const getScriptCacheKey = (filename, config, instrument: boolean) => { + const mtime = fs.statSync(filename).mtime; + return filename + '_' + mtime.getTime() + (instrument ? '_instrumented' : ''); +}; + +const shouldTransform = (filename: Path, config: ProjectConfig): boolean => { + if (!ignoreCache.has(config)) { + if (!config.transformIgnorePatterns) { + ignoreCache.set(config, null); + } else { + ignoreCache.set( + config, + new RegExp(config.transformIgnorePatterns.join('|')), + ); + } + } + + const ignoreRegexp = ignoreCache.get(config); + const isIgnored = ignoreRegexp ? ignoreRegexp.test(filename) : false; + return ( + !!config.transform && + !!config.transform.length && + (!config.transformIgnorePatterns.length || !isIgnored) + ); +}; + +const wrap = content => + '({"' + + ScriptTransformer.EVAL_RESULT_VARIABLE + + '":function(module,exports,require,__dirname,__filename,global,jest){' + + content + + '\n}});'; + +ScriptTransformer.EVAL_RESULT_VARIABLE = 'Object.'; + +module.exports = ScriptTransformer; diff --git a/packages/jest-runtime/src/__tests__/__snapshots__/transform-test.js.snap b/packages/jest-runtime/src/__tests__/__snapshots__/transform-test.js.snap index 349891102b5c..f4311abae9c2 100644 --- a/packages/jest-runtime/src/__tests__/__snapshots__/transform-test.js.snap +++ b/packages/jest-runtime/src/__tests__/__snapshots__/transform-test.js.snap @@ -1,16 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`transform transforms a file properly 1`] = ` +exports[`ScriptTransformer transforms a file properly 1`] = ` "({\\"Object.\\":function(module,exports,require,__dirname,__filename,global,jest){/* istanbul ignore next */var cov_25u22311x4 = function () {var path = \\"/fruits/banana.js\\",hash = \\"04636d4ae73b4b3e24bf6fba39e08c946fd0afb5\\",global = new Function('return this')(),gcv = \\"__coverage__\\",coverageData = { path: \\"/fruits/banana.js\\", statementMap: { \\"0\\": { start: { line: 1, column: 0 }, end: { line: 1, column: 26 } } }, fnMap: {}, branchMap: {}, s: { \\"0\\": 0 }, f: {}, b: {}, _coverageSchema: \\"332fd63041d2c1bcb487cc26dd0d5f7d97098a6c\\" },coverage = global[gcv] || (global[gcv] = {});if (coverage[path] && coverage[path].hash === hash) {return coverage[path];}coverageData.hash = hash;return coverage[path] = coverageData;}();++cov_25u22311x4.s[0];module.exports = \\"banana\\"; }});" `; -exports[`transform transforms a file properly 2`] = ` +exports[`ScriptTransformer transforms a file properly 2`] = ` "({\\"Object.\\":function(module,exports,require,__dirname,__filename,global,jest){/* istanbul ignore next */var cov_23yvu8etmu = function () {var path = \\"/fruits/kiwi.js\\",hash = \\"58d742d8c615e16eb5dffec9322e6ed1babde8f3\\",global = new Function('return this')(),gcv = \\"__coverage__\\",coverageData = { path: \\"/fruits/kiwi.js\\", statementMap: { \\"0\\": { start: { line: 1, column: 0 }, end: { line: 1, column: 30 } }, \\"1\\": { start: { line: 1, column: 23 }, end: { line: 1, column: 29 } } }, fnMap: { \\"0\\": { name: \\"(anonymous_0)\\", decl: { start: { line: 1, column: 17 }, end: { line: 1, column: 18 } }, loc: { start: { line: 1, column: 23 }, end: { line: 1, column: 29 } }, line: 1 } }, branchMap: {}, s: { \\"0\\": 0, \\"1\\": 0 }, f: { \\"0\\": 0 }, b: {}, _coverageSchema: \\"332fd63041d2c1bcb487cc26dd0d5f7d97098a6c\\" },coverage = global[gcv] || (global[gcv] = {});if (coverage[path] && coverage[path].hash === hash) {return coverage[path];}coverageData.hash = hash;return coverage[path] = coverageData;}();++cov_23yvu8etmu.s[0];module.exports = () => {/* istanbul ignore next */++cov_23yvu8etmu.f[0];++cov_23yvu8etmu.s[1];return \\"kiwi\\";}; }});" `; -exports[`transform uses multiple preprocessors 1`] = ` +exports[`ScriptTransformer uses multiple preprocessors 1`] = ` "({\\"Object.\\":function(module,exports,require,__dirname,__filename,global,jest){ const TRANSFORMED = { filename: '/fruits/banana.js', @@ -21,7 +21,7 @@ exports[`transform uses multiple preprocessors 1`] = ` }});" `; -exports[`transform uses multiple preprocessors 2`] = ` +exports[`ScriptTransformer uses multiple preprocessors 2`] = ` "({\\"Object.\\":function(module,exports,require,__dirname,__filename,global,jest){ module.exports = { filename: /styles/App.css, @@ -31,12 +31,12 @@ exports[`transform uses multiple preprocessors 2`] = ` }});" `; -exports[`transform uses multiple preprocessors 3`] = ` +exports[`ScriptTransformer uses multiple preprocessors 3`] = ` "({\\"Object.\\":function(module,exports,require,__dirname,__filename,global,jest){module.exports = \\"react\\"; }});" `; -exports[`transform uses the supplied preprocessor 1`] = ` +exports[`ScriptTransformer uses the supplied preprocessor 1`] = ` "({\\"Object.\\":function(module,exports,require,__dirname,__filename,global,jest){ const TRANSFORMED = { filename: '/fruits/banana.js', @@ -47,7 +47,7 @@ exports[`transform uses the supplied preprocessor 1`] = ` }});" `; -exports[`transform uses the supplied preprocessor 2`] = ` +exports[`ScriptTransformer uses the supplied preprocessor 2`] = ` "({\\"Object.\\":function(module,exports,require,__dirname,__filename,global,jest){module.exports = \\"react\\"; }});" `; diff --git a/packages/jest-runtime/src/__tests__/instrumentation-test.js b/packages/jest-runtime/src/__tests__/instrumentation-test.js index e7e59d21f6f3..2872f9c593bf 100644 --- a/packages/jest-runtime/src/__tests__/instrumentation-test.js +++ b/packages/jest-runtime/src/__tests__/instrumentation-test.js @@ -21,14 +21,16 @@ const FILE_PATH_TO_INSTRUMENT = path.resolve( it('instruments files', () => { const vm = require('vm'); - const transform = require('../transform'); + const ScriptTransformer = require('../ScriptTransformer'); const config = { cache: false, cacheDirectory: os.tmpdir(), collectCoverage: true, rootDir: '/', }; - const instrumented = transform(FILE_PATH_TO_INSTRUMENT, config).script; + const instrumented = new ScriptTransformer(config).transform( + FILE_PATH_TO_INSTRUMENT, + ).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 196de7d1be52..e29c87e2555e 100644 --- a/packages/jest-runtime/src/__tests__/transform-test.js +++ b/packages/jest-runtime/src/__tests__/transform-test.js @@ -84,14 +84,14 @@ const getCachePath = (fs, config) => { return null; }; +let ScriptTransformer; let config; let fs; let mockFs; let object; -let transform; let vm; -describe('transform', () => { +describe('ScriptTransformer', () => { const reset = () => { jest.resetModules(); @@ -140,14 +140,15 @@ describe('transform', () => { transformIgnorePatterns: ['/node_modules/'], }; - transform = require('../transform'); + ScriptTransformer = require('../ScriptTransformer'); }; beforeEach(reset); it('transforms a file properly', () => { config.collectCoverage = true; - const response = transform('/fruits/banana.js', config).script; + const scriptTransformer = new ScriptTransformer(config); + const response = scriptTransformer.transform('/fruits/banana.js').script; expect(response instanceof vm.Script).toBe(true); expect(vm.Script.mock.calls[0][0]).toMatchSnapshot(); @@ -157,26 +158,24 @@ describe('transform', () => { expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); // in-memory cache - const response2 = transform('/fruits/banana.js', config).script; + const response2 = scriptTransformer.transform('/fruits/banana.js').script; expect(response2).toBe(response); - transform('/fruits/kiwi.js', config); + scriptTransformer.transform('/fruits/kiwi.js'); const snapshot = vm.Script.mock.calls[1][0]; expect(snapshot).toMatchSnapshot(); - transform( - '/fruits/kiwi.js', + new ScriptTransformer( Object.assign({}, config, {collectCoverage: true}), - ); + ).transform('/fruits/kiwi.js'); expect(vm.Script.mock.calls[0][0]).not.toEqual(snapshot); expect(vm.Script.mock.calls[0][0]).not.toMatch(/instrumented kiwi/); // If we disable coverage, we get a different result. - transform( - '/fruits/kiwi.js', + new ScriptTransformer( Object.assign({}, config, {collectCoverage: false}), - ); + ).transform('/fruits/kiwi.js'); expect(vm.Script.mock.calls[1][0]).toEqual(snapshot); }); @@ -184,14 +183,14 @@ describe('transform', () => { config = Object.assign(config, { transform: [['^.+\\.js$', 'test-preprocessor']], }); - - transform('/fruits/banana.js', config); + const scriptTransformer = new ScriptTransformer(config); + scriptTransformer.transform('/fruits/banana.js'); expect(require('test-preprocessor').getCacheKey).toBeCalled(); expect(vm.Script.mock.calls[0][0]).toMatchSnapshot(); - transform('/node_modules/react.js', config); + scriptTransformer.transform('/node_modules/react.js'); // ignores preprocessor expect(vm.Script.mock.calls[1][0]).toMatchSnapshot(); }); @@ -203,16 +202,17 @@ describe('transform', () => { ['^.+\\.css$', 'css-preprocessor'], ], }); + const scriptTransformer = new ScriptTransformer(config); - transform('/fruits/banana.js', config); - transform('/styles/App.css', config); + scriptTransformer.transform('/fruits/banana.js'); + scriptTransformer.transform('/styles/App.css'); expect(require('test-preprocessor').getCacheKey).toBeCalled(); expect(require('css-preprocessor').getCacheKey).toBeCalled(); expect(vm.Script.mock.calls[0][0]).toMatchSnapshot(); expect(vm.Script.mock.calls[1][0]).toMatchSnapshot(); - transform('/node_modules/react.js', config); + scriptTransformer.transform('/node_modules/react.js'); // ignores preprocessor expect(vm.Script.mock.calls[2][0]).toMatchSnapshot(); }); @@ -223,6 +223,7 @@ describe('transform', () => { mapCoverage: true, transform: [['^.+\\.js$', 'preprocessor-with-sourcemaps']], }); + const scriptTransformer = new ScriptTransformer(config); const map = { mappings: ';AAAA', @@ -234,7 +235,7 @@ describe('transform', () => { map, }); - const result = transform('/fruits/banana.js', config); + const result = scriptTransformer.transform('/fruits/banana.js'); expect(result.sourceMapPath).toEqual(expect.any(String)); expect(fs.writeFileSync).toBeCalledWith( result.sourceMapPath, @@ -249,6 +250,7 @@ describe('transform', () => { mapCoverage: true, transform: [['^.+\\.js$', 'preprocessor-with-sourcemaps']], }); + const scriptTransformer = new ScriptTransformer(config); const sourceMap = JSON.stringify({ mappings: 'AAAA,IAAM,CAAC,GAAW,CAAC,CAAC', @@ -262,7 +264,7 @@ describe('transform', () => { require('preprocessor-with-sourcemaps').process.mockReturnValue(content); - const result = transform('/fruits/banana.js', config); + const result = scriptTransformer.transform('/fruits/banana.js'); expect(result.sourceMapPath).toEqual(expect.any(String)); expect(fs.writeFileSync).toBeCalledWith( result.sourceMapPath, @@ -277,6 +279,7 @@ describe('transform', () => { mapCoverage: false, transform: [['^.+\\.js$', 'preprocessor-with-sourcemaps']], }); + const scriptTransformer = new ScriptTransformer(config); const map = { mappings: ';AAAA', @@ -288,7 +291,7 @@ describe('transform', () => { map, }); - const result = transform('/fruits/banana.js', config); + const result = scriptTransformer.transform('/fruits/banana.js'); expect(result.sourceMapPath).toBeFalsy(); expect(fs.writeFileSync).toHaveBeenCalledTimes(1); }); @@ -297,7 +300,8 @@ describe('transform', () => { const transformConfig = Object.assign(config, { transform: [['^.+\\.js$', 'test-preprocessor']], }); - transform('/fruits/banana.js', transformConfig); + let scriptTransformer = new ScriptTransformer(transformConfig); + scriptTransformer.transform('/fruits/banana.js'); const cachePath = getCachePath(mockFs, config); expect(fs.writeFileSync).toBeCalled(); @@ -310,7 +314,8 @@ describe('transform', () => { // Restore the cached fs mockFs = mockFsCopy; - transform('/fruits/banana.js', transformConfig); + scriptTransformer = new ScriptTransformer(transformConfig); + scriptTransformer.transform('/fruits/banana.js'); expect(fs.readFileSync.mock.calls.length).toBe(2); expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); @@ -322,7 +327,8 @@ describe('transform', () => { reset(); mockFs = mockFsCopy; transformConfig.cache = false; - transform('/fruits/banana.js', transformConfig); + scriptTransformer = new ScriptTransformer(transformConfig); + scriptTransformer.transform('/fruits/banana.js'); expect(fs.readFileSync.mock.calls.length).toBe(1); expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); diff --git a/packages/jest-runtime/src/index.js b/packages/jest-runtime/src/index.js index 4f5dbc44b1d9..5b93cd41e9a0 100644 --- a/packages/jest-runtime/src/index.js +++ b/packages/jest-runtime/src/index.js @@ -19,6 +19,7 @@ import type {MockFunctionMetadata, ModuleMocker} from 'types/Mock'; const HasteMap = require('jest-haste-map'); const Resolver = require('jest-resolve'); +const ScriptTransformer = require('./ScriptTransformer'); const {createDirectory} = require('jest-util'); const {escapePathForRegex} = require('jest-regex-util'); @@ -26,7 +27,6 @@ const fs = require('graceful-fs'); const path = require('path'); const shouldInstrument = require('./shouldInstrument'); const stripBOM = require('strip-bom'); -const transform = require('./transform'); type Module = {| children?: Array, @@ -76,6 +76,8 @@ const mockParentModule = { const unmockRegExpCache = new WeakMap(); class Runtime { + static ScriptTransformer: Class; + _cacheFS: CacheFS; _config: ProjectConfig; _currentlyExecutingModulePath: string; @@ -93,6 +95,7 @@ class Runtime { _shouldMockModuleCache: BooleanObject; _shouldUnmockTransitiveDependenciesCache: BooleanObject; _sourceMapRegistry: {[key: string]: string}; + _scriptTransformer: ScriptTransformer; _transitiveShouldMock: BooleanObject; _unmockList: ?RegExp; _virtualMocks: BooleanObject; @@ -103,21 +106,21 @@ class Runtime { resolver: Resolver, cacheFS?: CacheFS, ) { - this._moduleRegistry = Object.create(null); - this._internalModuleRegistry = Object.create(null); - this._mockRegistry = Object.create(null); - this._sourceMapRegistry = Object.create(null); this._cacheFS = cacheFS || Object.create(null); this._config = config; - this._environment = environment; - this._resolver = resolver; - this._moduleMocker = this._environment.moduleMocker; - this._currentlyExecutingModulePath = ''; + this._environment = environment; this._explicitShouldMock = Object.create(null); + this._internalModuleRegistry = Object.create(null); this._isCurrentlyExecutingManualMock = null; this._mockFactories = Object.create(null); + this._mockRegistry = Object.create(null); + this._moduleMocker = this._environment.moduleMocker; + this._moduleRegistry = Object.create(null); + this._resolver = resolver; + this._scriptTransformer = new ScriptTransformer(config); this._shouldAutoMock = config.automock; + this._sourceMapRegistry = Object.create(null); this._virtualMocks = Object.create(null); this._mockMetaDataCache = Object.create(null); @@ -162,15 +165,6 @@ class Runtime { return shouldInstrument(filename, config); } - static transformSource( - filename: Path, - config: ProjectConfig, - content: string, - instrument: boolean, - ) { - return transform.transformSource(filename, config, content, instrument); - } - static createContext( config: ProjectConfig, options: { @@ -471,9 +465,8 @@ class Runtime { localModule.paths = this._resolver.getModulePaths(dirname); localModule.require = this._createRequireImplementation(filename, options); - const transformedFile = transform( + const transformedFile = this._scriptTransformer.transform( filename, - this._config, { isInternalModule, }, @@ -485,7 +478,7 @@ class Runtime { } const wrapper = this._environment.runScript(transformedFile.script)[ - transform.EVAL_RESULT_VARIABLE + ScriptTransformer.EVAL_RESULT_VARIABLE ]; wrapper.call( localModule.exports, // module context @@ -738,4 +731,5 @@ class Runtime { } } +Runtime.ScriptTransformer = ScriptTransformer; module.exports = Runtime; diff --git a/packages/jest-runtime/src/transform.js b/packages/jest-runtime/src/transform.js deleted file mode 100644 index 2d9ab5d27419..000000000000 --- a/packages/jest-runtime/src/transform.js +++ /dev/null @@ -1,428 +0,0 @@ -/** - * 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 {Path, ProjectConfig} from 'types/Config'; -import type { - Transformer, - TransformedSource, - BuiltTransformResult, -} from 'types/Transform'; -const createDirectory = require('jest-util').createDirectory; -const crypto = require('crypto'); -const fs = require('graceful-fs'); -const getCacheFilePath = require('jest-haste-map').getCacheFilePath; -const path = require('path'); -const shouldInstrument = require('./shouldInstrument'); -const stableStringify = require('json-stable-stringify'); -const vm = require('vm'); -const slash = require('slash'); - -const VERSION = require('../package.json').version; - -type Options = {| - isInternalModule?: boolean, -|}; - -type TransformerMap = Map; - -const EVAL_RESULT_VARIABLE = 'Object.'; - -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(); -const transformCache: WeakMap = new WeakMap(); - -const removeFile = (path: Path) => { - try { - fs.unlinkSync(path); - } catch (e) {} -}; - -const getCacheKey = ( - fileData: string, - filename: Path, - config: ProjectConfig, - instrument: boolean, -): string => { - if (!configToJsonMap.has(config)) { - // We only need this set of config options that can likely influence - // cached output instead of all config options. - configToJsonMap.set( - config, - stableStringify({ - cacheDirectory: config.cacheDirectory, - collectCoverage: config.collectCoverage, - collectCoverageFrom: config.collectCoverageFrom, - collectCoverageOnlyFrom: config.collectCoverageOnlyFrom, - coveragePathIgnorePatterns: config.coveragePathIgnorePatterns, - haste: config.haste, - moduleFileExtensions: config.moduleFileExtensions, - moduleNameMapper: config.moduleNameMapper, - rootDir: config.rootDir, - roots: config.roots, - testMatch: config.testMatch, - testRegex: config.testRegex, - transform: config.transform, - transformIgnorePatterns: config.transformIgnorePatterns, - }), - ); - } - const configString = configToJsonMap.get(config) || ''; - const transformer = getTransformer(filename, config); - - if (transformer && typeof transformer.getCacheKey === 'function') { - return transformer.getCacheKey(fileData, filename, configString, { - instrument, - }); - } else { - return crypto - .createHash('md5') - .update(fileData) - .update(configString) - .update(instrument ? 'instrument' : '') - .digest('hex'); - } -}; - -const writeCacheFile = (cachePath: Path, fileData: string) => { - try { - fs.writeFileSync(cachePath, fileData, 'utf8'); - } catch (e) { - e.message = - 'jest: failed to cache transform results in: ' + - cachePath + - '\nFailure message: ' + - e.message; - removeFile(cachePath); - throw e; - } -}; - -const wrap = content => - '({"' + - EVAL_RESULT_VARIABLE + - '":function(module,exports,require,__dirname,__filename,global,jest){' + - content + - '\n}});'; - -const readCacheFile = (filename: Path, cachePath: Path): ?string => { - if (!fs.existsSync(cachePath)) { - return null; - } - - let fileData; - try { - fileData = fs.readFileSync(cachePath, 'utf8'); - } catch (e) { - e.message = 'jest: failed to read cache file: ' + cachePath; - removeFile(cachePath); - throw e; - } - - if (fileData == null) { - // We must have somehow created the file but failed to write to it, - // let's delete it and retry. - removeFile(cachePath); - } - return fileData; -}; - -const getScriptCacheKey = (filename, config, instrument: boolean) => { - const mtime = fs.statSync(filename).mtime; - return filename + '_' + mtime.getTime() + (instrument ? '_instrumented' : ''); -}; - -const shouldTransform = (filename: Path, config: ProjectConfig): boolean => { - if (!ignoreCache.has(config)) { - if (!config.transformIgnorePatterns) { - ignoreCache.set(config, null); - } else { - ignoreCache.set( - config, - new RegExp(config.transformIgnorePatterns.join('|')), - ); - } - } - - const ignoreRegexp = ignoreCache.get(config); - const isIgnored = ignoreRegexp ? ignoreRegexp.test(filename) : false; - return ( - !!config.transform && - !!config.transform.length && - (!config.transformIgnorePatterns.length || !isIgnored) - ); -}; - -const getFileCachePath = ( - filename: Path, - config: ProjectConfig, - content: string, - instrument: boolean, -): Path => { - const baseCacheDir = getCacheFilePath( - config.cacheDirectory, - 'jest-transform-cache-' + config.name, - VERSION, - ); - const cacheKey = getCacheKey(content, filename, config, instrument); - // Create sub folders based on the cacheKey to avoid creating one - // directory with many files. - const cacheDir = path.join(baseCacheDir, cacheKey[0] + cacheKey[1]); - const cachePath = slash( - path.join( - cacheDir, - path.basename(filename, path.extname(filename)) + '_' + cacheKey, - ), - ); - createDirectory(cacheDir); - - return cachePath; -}; - -const getTransformer = ( - filename: string, - config: ProjectConfig, -): ?Transformer => { - const transformData = transformCache.get(config); - const transformFileData = transformData ? transformData.get(filename) : null; - - if (transformFileData) { - return transformFileData; - } - - let transform: ?Transformer; - if (!config.transform || !config.transform.length) { - transform = null; - } else { - let transformPath = null; - for (let i = 0; i < config.transform.length; i++) { - if (new RegExp(config.transform[i][0]).test(filename)) { - transformPath = config.transform[i][1]; - break; - } - } - if (transformPath) { - // $FlowFixMe - transform = (require(transformPath): Transformer); - if (typeof transform.process !== 'function') { - throw new TypeError( - 'Jest: a transform must export a `process` function.', - ); - } - if (typeof transform.createTransformer === 'function') { - transform = transform.createTransformer(); - } - } - } - if (!transformCache.has(config)) { - transformCache.set(config, new Map()); - } - - const cache = transformCache.get(config); - // This is definitely set at this point but Flow requires this check. - if (cache) { - cache.set(filename, transform); - } - return transform; -}; - -const stripShebang = content => { - // If the file data starts with a shebang remove it. Leaves the empty line - // to keep stack trace line numbers correct. - if (content.startsWith('#!')) { - return content.replace(/^#!.*/, ''); - } else { - return content; - } -}; - -const instrumentFile = ( - content: string, - filename: Path, - config: ProjectConfig, -): string => { - // NOTE: Keeping these requires inside this function reduces a single run - // time by 2sec if not running in `--coverage` mode - const babel = require('babel-core'); - const babelPluginIstanbul = require('babel-plugin-istanbul').default; - - return babel.transform(content, { - auxiliaryCommentBefore: ' istanbul ignore next ', - babelrc: false, - filename, - plugins: [ - [ - babelPluginIstanbul, - { - cwd: config.rootDir, // files outside `cwd` will not be instrumented - exclude: [], - useInlineSourceMaps: false, - }, - ], - ], - retainLines: true, - }).code; -}; - -const transformSource = ( - filename: Path, - config: ProjectConfig, - content: string, - instrument: boolean, -) => { - 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 code = config.cache ? readCacheFile(filename, cacheFilePath) : null; - - if (code) { - return { - code, - sourceMapPath, - }; - } - - let transformed: TransformedSource = { - code: content, - map: null, - }; - - if (transform && shouldTransform(filename, config)) { - const processed = transform.process(content, filename, config, { - instrument, - }); - - 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 transformDidInstrument = transform && transform.canInstrument; - - 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, code); - - return { - code, - sourceMapPath, - }; -}; - -const transformAndBuildScript = ( - filename: Path, - config: ProjectConfig, - options: ?Options, - instrument: boolean, - fileSource?: string, -): BuiltTransformResult => { - const isInternalModule = !!(options && options.isInternalModule); - const content = stripShebang(fileSource || fs.readFileSync(filename, 'utf8')); - let wrappedCode: string; - let sourceMapPath: ?string = null; - const willTransform = - !isInternalModule && (shouldTransform(filename, config) || instrument); - - try { - if (willTransform) { - const transformedSource = transformSource( - filename, - config, - content, - instrument, - ); - - wrappedCode = wrap(transformedSource.code); - sourceMapPath = transformedSource.sourceMapPath; - } else { - wrappedCode = wrap(content); - } - - return { - script: new vm.Script(wrappedCode, {displayErrors: true, filename}), - sourceMapPath, - }; - } catch (e) { - if (e.codeFrame) { - e.stack = e.codeFrame; - } - - if (config.logTransformErrors) { - console.error( - `FILENAME: ${filename}\n` + - `TRANSFORM: ${willTransform.toString()}\n` + - `INSTRUMENT: ${instrument.toString()}\n` + - `SOURCE:\n` + - String(wrappedCode), - ); - } - - throw e; - } -}; - -module.exports = ( - filename: Path, - config: ProjectConfig, - options: Options, - fileSource?: string, -): BuiltTransformResult => { - const instrument = shouldInstrument(filename, config); - const scriptCacheKey = getScriptCacheKey(filename, config, instrument); - let result = cache.get(scriptCacheKey); - if (result) { - return result; - } else { - result = transformAndBuildScript( - filename, - config, - options, - instrument, - fileSource, - ); - cache.set(scriptCacheKey, result); - return result; - } -}; - -module.exports.EVAL_RESULT_VARIABLE = EVAL_RESULT_VARIABLE; -module.exports.transformSource = transformSource; diff --git a/types/Transform.js b/types/Transform.js index baf0003afa36..1ca3a284ac38 100644 --- a/types/Transform.js +++ b/types/Transform.js @@ -17,7 +17,7 @@ export type TransformedSource = {| map: ?Object | string, |}; -export type BuiltTransformResult = {| +export type TransformResult = {| script: Script, sourceMapPath: ?string, |};