diff --git a/packages/jest-transform/src/ScriptTransformer.ts b/packages/jest-transform/src/ScriptTransformer.ts index 46ae3afb049f..cdab2e1d73bf 100644 --- a/packages/jest-transform/src/ScriptTransformer.ts +++ b/packages/jest-transform/src/ScriptTransformer.ts @@ -120,6 +120,40 @@ export default class ScriptTransformer { } } + private async _getCacheKeyAsync( + fileData: string, + filename: Config.Path, + instrument: boolean, + supportsDynamicImport: boolean, + supportsStaticESM: boolean, + ): Promise { + const configString = this._cache.configString; + const transformer = await this._getTransformerAsync(filename); + + if (transformer && typeof transformer.getCacheKeyAsync === 'function') { + return createHash('md5') + .update( + 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'); + } + } + private _getFileCachePath( filename: Config.Path, content: string, @@ -153,6 +187,39 @@ export default class ScriptTransformer { return cachePath; } + private async _getFileCachePathAsync( + filename: Config.Path, + content: string, + instrument: boolean, + 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, + 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]); + const cacheFilenamePrefix = path + .basename(filename, path.extname(filename)) + .replace(/\W/g, ''); + const cachePath = slash( + path.join(cacheDir, cacheFilenamePrefix + '_' + cacheKey), + ); + createDirectory(cacheDir); + + return cachePath; + } + private _getTransformPath(filename: Config.Path) { const transformRegExp = this._cache.transformRegExp; if (!transformRegExp) { @@ -171,6 +238,40 @@ export default class ScriptTransformer { return undefined; } + private async _getTransformerAsync(filename: Config.Path) { + let transform: Transformer | null = null; + 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; + } + + transform = await import(transformPath); + + if (!transform) { + throw new TypeError('Jest: a transform must export something.'); + } + const transformerConfig = this._transformConfigCache.get(transformPath); + if (typeof transform.createTransformer === 'function') { + transform = transform.createTransformer(transformerConfig); + } + if ( + typeof transform.process !== 'function' && + typeof transform.processAsync !== 'function') { + throw new TypeError( + 'Jest: a transform must export a `process` or `processAsync` function.', + ); + } + this._transformCache.set(transformPath, transform); + } + return transform; + } + private _getTransformer(filename: Config.Path) { let transform: Transformer | null = null; if (!this._config.transform || !this._config.transform.length) { @@ -390,6 +491,135 @@ export default class ScriptTransformer { }; } + // 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); + + // 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, + }; + } + + let transformed: TransformedSource = { + code: content, + map: null, + }; + + if (transform && shouldCallTransform) { + const 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 { + throw new TypeError( + "Jest: a transform's `process` function must return a string, " + + 'or an object with `code` key containing this string.', + ); + } + } + + 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.', + ); + } + } + + // 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; + + const instrumented = this._instrumentFile( + filename, + transformed, + 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; + } + + writeCodeCacheFile(cacheFilePath, code); + + return { + code, + originalCode: content, + sourceMapPath, + }; + } + private _transformAndBuildScript( filename: Config.Path, options: Options, diff --git a/packages/jest-transform/src/types.ts b/packages/jest-transform/src/types.ts index b984980e1112..458a4e773ed8 100644 --- a/packages/jest-transform/src/types.ts +++ b/packages/jest-transform/src/types.ts @@ -55,21 +55,70 @@ export interface CacheKeyOptions extends TransformOptions { rootDir: string; } -export interface Transformer { +interface SyncTransFormer { canInstrument?: boolean; - createTransformer?: (options?: any) => Transformer; + createTransformer?: (options?: any) => SyncTransFormer; getCacheKey?: ( - fileData: string, + fileDate: string, filePath: Config.Path, configStr: string, options: CacheKeyOptions, ) => string; + getCacheKeyAsync?: ( + fileDate: string, + filePath: Config.Path, + configStr: string, + options: CacheKeyOptions, + ) => Promise; + process: ( sourceText: string, sourcePath: Config.Path, config: Config.ProjectConfig, options?: TransformOptions, ) => TransformedSource; + + processAsync?: ( + sourceText: string, + sourcePath: Config.Path, + config: Config.ProjectConfig, + options?: TransformOptions, + ) => Promise; } + +interface AsyncTransformer { + canInstrument?: boolean; + createTransformer?: (options?: any) => AsyncTransformer; + + getCacheKey?: ( + fileDate: string, + filePath: Config.Path, + configStr: string, + options: CacheKeyOptions, + ) => string; + + getCacheKeyAsync?: ( + fileDate: string, + filePath: Config.Path, + configStr: string, + options: CacheKeyOptions, + ) => Promise; + + process: ( + sourceText: string, + sourcePath: Config.Path, + config: Config.ProjectConfig, + options?: TransformOptions, + ) => TransformedSource; + + processAsync?: ( + sourceText: string, + sourcePath: Config.Path, + config: Config.ProjectConfig, + options?: TransformOptions, + ) => Promise; +} + +export type Transformer = SyncTransFormer | AsyncTransformer; \ No newline at end of file