diff --git a/packages/jest-transform/src/ScriptTransformer.ts b/packages/jest-transform/src/ScriptTransformer.ts index cdab2e1d73bf..2de2fba00988 100644 --- a/packages/jest-transform/src/ScriptTransformer.ts +++ b/packages/jest-transform/src/ScriptTransformer.ts @@ -86,27 +86,16 @@ export default class ScriptTransformer { this._cache = projectCache; } - private _getCacheKey( + private _buildCacheKeyFromFileInfo( fileData: string, filename: Config.Path, instrument: boolean, - supportsDynamicImport: boolean, - supportsStaticESM: boolean, + configString: string, + transformerCacheKey?: string, ): string { - const configString = this._cache.configString; - const transformer = this._getTransformer(filename); - - if (transformer && typeof transformer.getCacheKey === 'function') { + if (transformerCacheKey) { return createHash('md5') - .update( - transformer.getCacheKey(fileData, filename, configString, { - config: this._config, - instrument, - rootDir: this._config.rootDir, - supportsDynamicImport, - supportsStaticESM, - }), - ) + .update(transformerCacheKey) .update(CACHE_VERSION) .digest('hex'); } else { @@ -120,6 +109,38 @@ export default class ScriptTransformer { } } + private _getCacheKey( + fileData: string, + filename: Config.Path, + instrument: boolean, + supportsDynamicImport: boolean, + supportsStaticESM: boolean, + ): string { + const configString = this._cache.configString; + const transformer = this._getTransformer(filename); + let transformerCacheKey = undefined; + + if (transformer && typeof transformer.getCacheKey === 'function') { + transformerCacheKey = transformer.getCacheKey( + fileData, + filename, + configString, + { + config: this._config, + instrument, + rootDir: this._config.rootDir, + supportsDynamicImport, + supportsStaticESM, + }); + } + return this._buildCacheKeyFromFileInfo( + fileData, + filename, + instrument, + configString, + transformerCacheKey); + } + private async _getCacheKeyAsync( fileData: string, filename: Config.Path, @@ -129,50 +150,36 @@ export default class ScriptTransformer { ): Promise { const configString = this._cache.configString; const transformer = await this._getTransformerAsync(filename); + let transformerCacheKey = undefined; if (transformer && typeof transformer.getCacheKeyAsync === 'function') { - return createHash('md5') - .update( + transformerCacheKey = await transformer.getCacheKeyAsync(fileData, filename, configString, { config: this._config, instrument, rootDir: this._config.rootDir, supportsDynamicImport, supportsStaticESM, - }), - ) - .update(CACHE_VERSION) - .digest('hex'); - } else { - return createHash('md5') - .update(fileData) - .update(configString) - .update(instrument ? 'instrument' : '') - .update(filename) - .update(CACHE_VERSION) - .digest('hex'); + }); } + return this._buildCacheKeyFromFileInfo( + fileData, + filename, + instrument, + configString, + transformerCacheKey + ); } - private _getFileCachePath( + private _createFolderFromCacheKey( filename: Config.Path, - content: string, - instrument: boolean, - supportsDynamicImport: boolean, - supportsStaticESM: boolean, - ): Config.Path { + cacheKey: string, + ): string { const baseCacheDir = HasteMap.getCacheFilePath( this._config.cacheDirectory, 'jest-transform-cache-' + this._config.name, VERSION, ); - const cacheKey = this._getCacheKey( - content, - filename, - instrument, - supportsDynamicImport, - supportsStaticESM, - ); // Create sub folders based on the cacheKey to avoid creating one // directory with many files. const cacheDir = path.join(baseCacheDir, cacheKey[0] + cacheKey[1]); @@ -187,6 +194,28 @@ export default class ScriptTransformer { return cachePath; } + private _getFileCachePath( + filename: Config.Path, + content: string, + instrument: boolean, + supportsDynamicImport: boolean, + supportsStaticESM: boolean, + ): Config.Path { + + const cacheKey = this._getCacheKey( + content, + filename, + instrument, + supportsDynamicImport, + supportsStaticESM, + ); + + return this._createFolderFromCacheKey( + filename, + cacheKey + ); + } + private async _getFileCachePathAsync( filename: Config.Path, content: string, @@ -194,11 +223,7 @@ export default class ScriptTransformer { supportsDynamicImport: boolean, supportsStaticESM: boolean, ): Promise { - const baseCacheDir = HasteMap.getCacheFilePath( - this._config.cacheDirectory, - 'jest-transform-cache-' + this._config.name, - VERSION, - ); + const cacheKey = await this._getCacheKeyAsync( content, filename, @@ -206,18 +231,10 @@ export default class ScriptTransformer { supportsDynamicImport, supportsStaticESM, ); - // 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 cacheFilenamePrefix = path - .basename(filename, path.extname(filename)) - .replace(/\W/g, ''); - const cachePath = slash( - path.join(cacheDir, cacheFilenamePrefix + '_' + cacheKey), - ); - createDirectory(cacheDir); - return cachePath; + return this._createFolderFromCacheKey( + filename, + cacheKey); } private _getTransformPath(filename: Config.Path) { @@ -362,58 +379,29 @@ export default class ScriptTransformer { this._getTransformer(filepath); } - // TODO: replace third argument with TransformOptions in Jest 26 - transformSource( - filepath: Config.Path, + private _buildTransformResult( + filename: string, + cacheFilePath: string, content: string, + transform: Transformer | null, + shouldCallTransform: boolean | null | undefined, instrument: boolean, - supportsDynamicImport = false, - supportsStaticESM = false, - ): TransformResult { - const filename = this._getRealPath(filepath); - const transform = this._getTransformer(filename); - const cacheFilePath = this._getFileCachePath( - filename, - content, - instrument, - supportsDynamicImport, - supportsStaticESM, - ); - let sourceMapPath: Config.Path | null = cacheFilePath + '.map'; - // Ignore cache if `config.cache` is set (--no-cache) - let code = this._config.cache ? readCodeCacheFile(cacheFilePath) : null; - - const shouldCallTransform = transform && this.shouldTransform(filename); - - // That means that the transform has a custom instrumentation - // logic and will handle it based on `config.collectCoverage` option - const transformWillInstrument = - shouldCallTransform && transform && transform.canInstrument; - - if (code) { - // This is broken: we return the code, and a path for the source map - // directly from the cache. But, nothing ensures the source map actually - // matches that source code. They could have gotten out-of-sync in case - // two separate processes write concurrently to the same cache files. - return { - code, - originalCode: content, - sourceMapPath, - }; - } - + supportsDynamicImport: boolean, + supportsStaticESM: boolean, + processed: TransformedSource | null, + sourceMapPath: Config.Path | null, + ):TransformResult { let transformed: TransformedSource = { code: content, map: null, }; - if (transform && shouldCallTransform) { - const processed = transform.process(content, filename, this._config, { - instrument, - supportsDynamicImport, - supportsStaticESM, - }); + // That means that the transform has a custom instrumentation + // logic and will handle it based on `config.collectCoverage` option + const transformWillInstrument = + shouldCallTransform && transform && transform.canInstrument; + if (transform && shouldCallTransform) { if (typeof processed === 'string') { transformed.code = processed; } else if (processed != null && typeof processed.code === 'string') { @@ -446,6 +434,7 @@ export default class ScriptTransformer { // Apply instrumentation to the code if necessary, keeping the instrumented code and new map let map = transformed.map; + let code; if (!transformWillInstrument && instrument) { /** * We can map the original source code to the instrumented code ONLY if @@ -477,6 +466,9 @@ export default class ScriptTransformer { if (map) { const sourceMapContent = typeof map === 'string' ? map : JSON.stringify(map); + + invariant(sourceMapPath, "We should always have default sourceMapPath") + writeCacheFile(sourceMapPath, sourceMapContent); } else { sourceMapPath = null; @@ -492,16 +484,16 @@ export default class ScriptTransformer { } // TODO: replace third argument with TransformOptions in Jest 26 - async transformSourceAsync( + transformSource( filepath: Config.Path, content: string, instrument: boolean, supportsDynamicImport = false, supportsStaticESM = false, - ): Promise { + ): TransformResult { const filename = this._getRealPath(filepath); - const transform = await this._getTransformerAsync(filename); - const cacheFilePath = await this._getFileCachePathAsync( + const transform = this._getTransformer(filename); // async + const cacheFilePath = this._getFileCachePath( // async filename, content, instrument, @@ -514,10 +506,7 @@ export default class ScriptTransformer { const shouldCallTransform = transform && this.shouldTransform(filename); - // That means that the transform has a custom instrumentation - // logic and will handle it based on `config.collectCoverage` option - const transformWillInstrument = - shouldCallTransform && transform && transform.canInstrument; + if (code) { // This is broken: we return the code, and a path for the source map @@ -531,23 +520,23 @@ export default class ScriptTransformer { }; } - let transformed: TransformedSource = { - code: content, - map: null, - }; + let processed: TransformedSource | null = null; if (transform && shouldCallTransform) { - const processed = transform.process(content, filename, this._config, { + if (typeof transform.process !== 'function') { + throw new TypeError( + "Jest: a synchronous transform mus export a `process` function." + ); + } + + processed = transform.process(content, filename, this._config, { instrument, supportsDynamicImport, supportsStaticESM, }); - if (typeof processed === 'string') { - transformed.code = processed; - } else if (processed != null && typeof processed.code === 'string') { - transformed = processed; - } else { + if (processed == null || + (typeof processed !== 'string' && typeof processed.code !== 'string')) { throw new TypeError( "Jest: a transform's `process` function must return a string, " + 'or an object with `code` key containing this string.', @@ -555,69 +544,94 @@ export default class ScriptTransformer { } } - if (!transformed.map) { - try { - //Could be a potential freeze here. - //See: https://github.com/facebook/jest/pull/5177#discussion_r158883570 - const inlineSourceMap = sourcemapFromSource(transformed.code); - if (inlineSourceMap) { - transformed.map = inlineSourceMap.toObject(); - } - } catch (e) { - const transformPath = this._getTransformPath(filename); - console.warn( - `jest-transform: The source map produced for the file ${filename} ` + - `by ${transformPath} was invalid. Proceeding without source ` + - 'mapping for that file.', - ); - } + return this._buildTransformResult( + filename, + cacheFilePath, + content, + transform, + shouldCallTransform, + instrument, + supportsDynamicImport, + supportsStaticESM, + processed, + sourceMapPath + ); + } + + // TODO: replace third argument with TransformOptions in Jest 26 + async transformSourceAsync( + filepath: Config.Path, + content: string, + instrument: boolean, + supportsDynamicImport = false, + supportsStaticESM = false, + ): Promise { + const filename = this._getRealPath(filepath); + const transform = await this._getTransformerAsync(filename); + const cacheFilePath = await this._getFileCachePathAsync( + filename, + content, + instrument, + supportsDynamicImport, + supportsStaticESM, + ); + let sourceMapPath: Config.Path | null = cacheFilePath + '.map'; + // Ignore cache if `config.cache` is set (--no-cache) + let code = this._config.cache ? readCodeCacheFile(cacheFilePath) : null; + + const shouldCallTransform = transform && this.shouldTransform(filename); + + if (code) { + // This is broken: we return the code, and a path for the source map + // directly from the cache. But, nothing ensures the source map actually + // matches that source code. They could have gotten out-of-sync in case + // two separate processes write concurrently to the same cache files. + return { + code, + originalCode: content, + sourceMapPath, + }; } - // Apply instrumentation to the code if necessary, keeping the instrumented code and new map - let map = transformed.map; - if (!transformWillInstrument && instrument) { - /** - * We can map the original source code to the instrumented code ONLY if - * - the process of transforming the code produced a source map e.g. ts-jest - * - we did not transform the source code - * - * Otherwise we cannot make any statements about how the instrumented code corresponds to the original code, - * and we should NOT emit any source maps - * - */ - const shouldEmitSourceMaps = - (transform != null && map != null) || transform == null; + let processed: TransformedSource | null = null; - const instrumented = this._instrumentFile( - filename, - transformed, + if (transform && shouldCallTransform) { + processed = transform.processAsync ? await transform.processAsync( + content, filename, this._config, { + instrument, + supportsDynamicImport, + supportsStaticESM, + } + ) : + transform.process(content, filename, this._config, { + instrument, supportsDynamicImport, supportsStaticESM, - shouldEmitSourceMaps, - ); - - code = - typeof instrumented === 'string' ? instrumented : instrumented.code; - map = typeof instrumented === 'string' ? null : instrumented.map; - } else { - code = transformed.code; - } + }); - if (map) { - const sourceMapContent = - typeof map === 'string' ? map : JSON.stringify(map); - writeCacheFile(sourceMapPath, sourceMapContent); - } else { - sourceMapPath = null; + if (processed == null || + (typeof processed !== 'string' && typeof processed.code !== 'string')) { + throw new TypeError( + "Jest: a transform's `process` function must return a string, " + + "or an object with `code` key containing this string. " + + "It's `processAsync` function must return that in a promise.", + ); + } } - writeCodeCacheFile(cacheFilePath, code); + return this._buildTransformResult( + filename, + cacheFilePath, + content, + transform, + shouldCallTransform, + instrument, + supportsDynamicImport, + supportsStaticESM, + processed, + sourceMapPath + ); - return { - code, - originalCode: content, - sourceMapPath, - }; } private _transformAndBuildScript( @@ -978,3 +992,9 @@ const calcTransformRegExp = (config: Config.ProjectConfig) => { return transformRegexp; }; + +function invariant(condition: unknown, message?: string): asserts condition { + if (!condition) { + throw new Error(message); + } +}