From fc92ed88f908a5075bc54760ceeec00a00e29b2e Mon Sep 17 00:00:00 2001 From: Yen-Chi Chen Date: Sun, 26 Apr 2020 18:05:16 +0200 Subject: [PATCH] Adding transformAsync --- CHANGELOG.md | 1 + packages/babel-jest/src/index.ts | 6 +- packages/jest-repl/src/cli/repl.ts | 5 +- .../jest-transform/src/ScriptTransformer.ts | 411 ++++-- .../src/__tests__/ScriptTransformer.test.ts | 1109 ++++++++++++++++- .../ScriptTransformer.test.ts.snap | 403 ++++++ packages/jest-transform/src/index.ts | 2 + packages/jest-transform/src/types.ts | 47 +- 8 files changed, 1859 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f6c31fb0d4a..60842ef395ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - `[jest-transform]` Pass config options defined in Jest's config to transformer's `process` and `getCacheKey` functions ([#10926](https://github.com/facebook/jest/pull/10926)) - `[jest-transform]` Add support for transformers written in ESM ([#11163](https://github.com/facebook/jest/pull/11163)) - `[jest-transform]` [**BREAKING**] Do not export `ScriptTransformer` class, instead export the async function `createScriptTransformer` ([#11163](https://github.com/facebook/jest/pull/11163)) +- `[jest-transform]` Async code transformations ([#9889](https://github.com/facebook/jest/pull/9889)) - `[jest-worker]` Add support for custom task queues and adds a `PriorityQueue` implementation. ([#10921](https://github.com/facebook/jest/pull/10921)) - `[jest-worker]` Add in-order scheduling policy to jest worker ([10902](https://github.com/facebook/jest/pull/10902)) diff --git a/packages/babel-jest/src/index.ts b/packages/babel-jest/src/index.ts index 874101146276..c0bff0b5d336 100644 --- a/packages/babel-jest/src/index.ts +++ b/packages/babel-jest/src/index.ts @@ -17,7 +17,7 @@ import * as fs from 'graceful-fs'; import slash = require('slash'); import type { TransformOptions as JestTransformOptions, - Transformer, + SyncTransformer, } from '@jest/transform'; import type {Config} from '@jest/types'; import {loadPartialConfig} from './loadBabelConfig'; @@ -26,7 +26,7 @@ const THIS_FILE = fs.readFileSync(__filename); const jestPresetPath = require.resolve('babel-preset-jest'); const babelIstanbulPlugin = require.resolve('babel-plugin-istanbul'); -type CreateTransformer = Transformer['createTransformer']; +type CreateTransformer = SyncTransformer['createTransformer']; const createTransformer: CreateTransformer = userOptions => { const inputOptions = userOptions ?? {}; @@ -160,7 +160,7 @@ const createTransformer: CreateTransformer = userOptions => { }; }; -const transformer: Transformer = { +const transformer: SyncTransformer = { ...createTransformer(), // Assigned here so only the exported transformer has `createTransformer`, // instead of all created transformers by the function diff --git a/packages/jest-repl/src/cli/repl.ts b/packages/jest-repl/src/cli/repl.ts index 8a68a190330e..6dde9dad8c18 100644 --- a/packages/jest-repl/src/cli/repl.ts +++ b/packages/jest-repl/src/cli/repl.ts @@ -11,10 +11,11 @@ declare const jestProjectConfig: Config.ProjectConfig; import * as path from 'path'; import * as repl from 'repl'; import {runInThisContext} from 'vm'; -import type {Transformer} from '@jest/transform'; +import type {SyncTransformer} from '@jest/transform'; import type {Config} from '@jest/types'; -let transformer: Transformer; +// TODO: support async as well +let transformer: SyncTransformer; let transformerConfig: unknown; const evalCommand: repl.REPLEval = ( diff --git a/packages/jest-transform/src/ScriptTransformer.ts b/packages/jest-transform/src/ScriptTransformer.ts index 7140f387ac83..7ce6c8d1554f 100644 --- a/packages/jest-transform/src/ScriptTransformer.ts +++ b/packages/jest-transform/src/ScriptTransformer.ts @@ -31,6 +31,8 @@ import type { Options, ReducedTransformOptions, StringMap, + SyncTransformer, + TransformOptions, TransformResult, TransformedSource, Transformer, @@ -66,18 +68,16 @@ async function waitForPromiseWithCleanup( class ScriptTransformer { private readonly _cache: ProjectCache; - private readonly _transformCache: Map< + private readonly _transformCache = new Map< Config.Path, {transformer: Transformer; transformerConfig: unknown} - >; + >(); private _transformsAreLoaded = false; constructor( private readonly _config: Config.ProjectConfig, private readonly _cacheFS: StringMap, ) { - this._transformCache = new Map(); - const configString = stableStringify(this._config); let projectCache = projectCaches.get(configString); @@ -95,6 +95,28 @@ class ScriptTransformer { this._cache = projectCache; } + private _buildCacheKeyFromFileInfo( + fileData: string, + filename: Config.Path, + transformOptions: TransformOptions, + transformerCacheKey: string | undefined, + ): string { + if (transformerCacheKey) { + return createHash('md5') + .update(transformerCacheKey) + .update(CACHE_VERSION) + .digest('hex'); + } + + return createHash('md5') + .update(fileData) + .update(transformOptions.configString) + .update(transformOptions.instrument ? 'instrument' : '') + .update(filename) + .update(CACHE_VERSION) + .digest('hex'); + } + private _getCacheKey( fileData: string, filename: Config.Path, @@ -103,42 +125,80 @@ class ScriptTransformer { const configString = this._cache.configString; const {transformer, transformerConfig = {}} = this._getTransformer(filename) || {}; + let transformerCacheKey = undefined; + + const transformOptions: TransformOptions = { + ...options, + cacheFS: this._cacheFS, + config: this._config, + configString, + transformerConfig, + }; - if (transformer && typeof transformer.getCacheKey === 'function') { - return createHash('md5') - .update( - transformer.getCacheKey(fileData, filename, { - ...options, - cacheFS: this._cacheFS, - config: this._config, - configString, - transformerConfig, - }), - ) - .update(CACHE_VERSION) - .digest('hex'); - } else { - return createHash('md5') - .update(fileData) - .update(configString) - .update(options.instrument ? 'instrument' : '') - .update(filename) - .update(CACHE_VERSION) - .digest('hex'); + if (typeof transformer?.getCacheKey === 'function') { + transformerCacheKey = transformer.getCacheKey( + fileData, + filename, + transformOptions, + ); } + + return this._buildCacheKeyFromFileInfo( + fileData, + filename, + transformOptions, + transformerCacheKey, + ); } - private _getFileCachePath( + private async _getCacheKeyAsync( + fileData: string, filename: Config.Path, - content: string, options: ReducedTransformOptions, + ): Promise { + const configString = this._cache.configString; + const {transformer, transformerConfig = {}} = + this._getTransformer(filename) || {}; + let transformerCacheKey = undefined; + + const transformOptions: TransformOptions = { + ...options, + cacheFS: this._cacheFS, + config: this._config, + configString, + transformerConfig, + }; + + if (transformer) { + const getCacheKey = + transformer.getCacheKeyAsync || transformer.getCacheKey; + + if (typeof getCacheKey === 'function') { + transformerCacheKey = await getCacheKey( + fileData, + filename, + transformOptions, + ); + } + } + + return this._buildCacheKeyFromFileInfo( + fileData, + filename, + transformOptions, + transformerCacheKey, + ); + } + + private _createFolderFromCacheKey( + filename: Config.Path, + cacheKey: string, ): Config.Path { const baseCacheDir = HasteMap.getCacheFilePath( this._config.cacheDirectory, 'jest-transform-cache-' + this._config.name, VERSION, ); - const cacheKey = this._getCacheKey(content, filename, options); // Create sub folders based on the cacheKey to avoid creating one // directory with many files. const cacheDir = path.join(baseCacheDir, cacheKey[0] + cacheKey[1]); @@ -153,6 +213,26 @@ class ScriptTransformer { return cachePath; } + private _getFileCachePath( + filename: Config.Path, + content: string, + options: ReducedTransformOptions, + ): Config.Path { + const cacheKey = this._getCacheKey(content, filename, options); + + return this._createFolderFromCacheKey(filename, cacheKey); + } + + private async _getFileCachePathAsync( + filename: Config.Path, + content: string, + options: ReducedTransformOptions, + ): Promise { + const cacheKey = await this._getCacheKeyAsync(content, filename, options); + + return this._createFolderFromCacheKey(filename, cacheKey); + } + private _getTransformPath(filename: Config.Path) { const transformRegExp = this._cache.transformRegExp; if (!transformRegExp) { @@ -201,12 +281,14 @@ class ScriptTransformer { if (typeof transformer.createTransformer === 'function') { transformer = transformer.createTransformer(transformerConfig); } - if (typeof transformer.process !== 'function') { + if ( + typeof transformer.process !== 'function' && + typeof transformer.processAsync !== 'function' + ) { throw new TypeError( - 'Jest: a transform must export a `process` function.', + 'Jest: a transform must export a `process` or `processAsync` function.', ); } - const res = {transformer, transformerConfig}; this._transformCache.set(transformPath, res); }, @@ -288,52 +370,22 @@ class ScriptTransformer { return input; } - transformSource( - filepath: Config.Path, + private _buildTransformResult( + filename: string, + cacheFilePath: string, content: string, + transformer: Transformer | undefined, + shouldCallTransform: boolean, options: ReducedTransformOptions, + processed: TransformedSource | null, + sourceMapPath: Config.Path | null, ): TransformResult { - const filename = tryRealpath(filepath); - const {transformer, transformerConfig = {}} = - this._getTransformer(filename) || {}; - const cacheFilePath = this._getFileCachePath(filename, content, options); - 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 = transformer && 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 && transformer && transformer.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 (transformer && shouldCallTransform) { - const processed = transformer.process(content, filename, { - ...options, - cacheFS: this._cacheFS, - config: this._config, - configString: this._cache.configString, - transformerConfig, - }); - if (typeof processed === 'string') { transformed.code = processed; } else if (processed != null && typeof processed.code === 'string') { @@ -341,7 +393,8 @@ class ScriptTransformer { } else { throw new TypeError( "Jest: a transform's `process` function must return a string, " + - 'or an object with `code` key containing this string.', + 'or an object with `code` key containing this string. ' + + "It's `processAsync` function must return a Promise resolving to it.", ); } } @@ -364,8 +417,14 @@ class ScriptTransformer { } } + // That means that the transform has a custom instrumentation + // logic and will handle it based on `config.collectCoverage` option + const transformWillInstrument = + shouldCallTransform && transformer && transformer.canInstrument; + // Apply instrumentation to the code if necessary, keeping the instrumented code and new map let map = transformed.map; + let code; if (!transformWillInstrument && options.instrument) { /** * We can map the original source code to the instrumented code ONLY if @@ -396,6 +455,9 @@ 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; @@ -410,6 +472,168 @@ class ScriptTransformer { }; } + transformSource( + filepath: Config.Path, + content: string, + options: ReducedTransformOptions, + ): TransformResult { + const filename = tryRealpath(filepath); + const {transformer, transformerConfig = {}} = + this._getTransformer(filename) || {}; + const cacheFilePath = this._getFileCachePath(filename, content, options); + const sourceMapPath: Config.Path = cacheFilePath + '.map'; + // Ignore cache if `config.cache` is set (--no-cache) + const code = this._config.cache ? readCodeCacheFile(cacheFilePath) : null; + + 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 processed = null; + + let shouldCallTransform = false; + + if (transformer && this.shouldTransform(filename)) { + shouldCallTransform = true; + + assertSyncTransformer(transformer, this._getTransformPath(filename)); + + processed = transformer.process(content, filename, { + ...options, + cacheFS: this._cacheFS, + config: this._config, + configString: this._cache.configString, + transformerConfig, + }); + } + + return this._buildTransformResult( + filename, + cacheFilePath, + content, + transformer, + shouldCallTransform, + options, + processed, + sourceMapPath, + ); + } + + async transformSourceAsync( + filepath: Config.Path, + content: string, + options: ReducedTransformOptions, + ): Promise { + const filename = tryRealpath(filepath); + const {transformer, transformerConfig = {}} = + this._getTransformer(filename) || {}; + const cacheFilePath = await this._getFileCachePathAsync( + filename, + content, + options, + ); + const sourceMapPath: Config.Path = cacheFilePath + '.map'; + // Ignore cache if `config.cache` is set (--no-cache) + const code = this._config.cache ? readCodeCacheFile(cacheFilePath) : null; + + 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 processed = null; + + let shouldCallTransform = false; + + if (transformer && this.shouldTransform(filename)) { + shouldCallTransform = true; + const process = transformer.processAsync || transformer.process; + + // This is probably dead code since `_getTransformerAsync` already asserts this + invariant( + typeof process === 'function', + 'A transformer must always export either a `process` or `processAsync`', + ); + + processed = await process(content, filename, { + ...options, + cacheFS: this._cacheFS, + config: this._config, + configString: this._cache.configString, + transformerConfig, + }); + } + + return this._buildTransformResult( + filename, + cacheFilePath, + content, + transformer, + shouldCallTransform, + options, + processed, + sourceMapPath, + ); + } + + private async _transformAndBuildScriptAsync( + filename: Config.Path, + options: Options, + transformOptions: ReducedTransformOptions, + fileSource?: string, + ): Promise { + const {isInternalModule} = options; + let fileContent = fileSource ?? this._cacheFS.get(filename); + if (!fileContent) { + fileContent = fs.readFileSync(filename, 'utf8'); + this._cacheFS.set(filename, fileContent); + } + const content = stripShebang(fileContent); + + let code = content; + let sourceMapPath: string | null = null; + + const willTransform = + !isInternalModule && + (transformOptions.instrument || this.shouldTransform(filename)); + + try { + if (willTransform) { + const transformedSource = await this.transformSourceAsync( + filename, + content, + transformOptions, + ); + + code = transformedSource.code; + sourceMapPath = transformedSource.sourceMapPath; + } + + return { + code, + originalCode: content, + sourceMapPath, + }; + } catch (e) { + throw handlePotentialSyntaxError(e); + } + } + private _transformAndBuildScript( filename: Config.Path, options: Options, @@ -453,6 +677,34 @@ class ScriptTransformer { } } + async transformAsync( + filename: Config.Path, + options: Options, + fileSource?: string, + ): Promise { + const instrument = + options.coverageProvider === 'babel' && + shouldInstrument(filename, options, this._config); + const scriptCacheKey = getScriptCacheKey(filename, instrument); + let result = this._cache.transformedFiles.get(scriptCacheKey); + if (result) { + return result; + } + + result = await this._transformAndBuildScriptAsync( + filename, + options, + {...options, instrument}, + fileSource, + ); + + if (scriptCacheKey) { + this._cache.transformedFiles.set(scriptCacheKey, result); + } + + return result; + } + transform( filename: Config.Path, options: Options, @@ -574,9 +826,7 @@ class ScriptTransformer { const ignoreRegexp = this._cache.ignorePatternsRegExp; const isIgnored = ignoreRegexp ? ignoreRegexp.test(filename) : false; - return ( - !!this._config.transform && !!this._config.transform.length && !isIgnored - ); + return this._config.transform.length !== 0 && !isIgnored; } } @@ -749,6 +999,23 @@ const calcTransformRegExp = (config: Config.ProjectConfig) => { return transformRegexp; }; +function invariant(condition: unknown, message?: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function assertSyncTransformer( + transformer: Transformer, + name: string | undefined, +): asserts transformer is SyncTransformer { + invariant(name); + invariant( + typeof transformer.process === 'function', + `Jest: synchronous transformer ${name} must export a "process" function.`, + ); +} + export type TransformerType = ScriptTransformer; export async function createScriptTransformer( diff --git a/packages/jest-transform/src/__tests__/ScriptTransformer.test.ts b/packages/jest-transform/src/__tests__/ScriptTransformer.test.ts index ed0bbffb937e..0b3dee805b39 100644 --- a/packages/jest-transform/src/__tests__/ScriptTransformer.test.ts +++ b/packages/jest-transform/src/__tests__/ScriptTransformer.test.ts @@ -67,6 +67,28 @@ jest.mock( {virtual: true}, ); +jest.mock( + 'test_async_preprocessor', + () => { + const escapeStrings = (str: string) => str.replace(/'/, `'`); + + const transformer: Transformer = { + getCacheKeyAsync: jest.fn().mockResolvedValue('ab'), + processAsync: async (content, filename, config) => + require('dedent')` + const TRANSFORMED = { + filename: '${escapeStrings(filename)}', + script: '${escapeStrings(content)}', + config: '${escapeStrings(JSON.stringify(config))}', + }; + `, + }; + + return transformer; + }, + {virtual: true}, +); + jest.mock( 'configureable-preprocessor', () => ({ @@ -86,6 +108,15 @@ jest.mock( {virtual: true}, ); +jest.mock( + 'cache_fs_async_preprocessor', + () => ({ + getCacheKeyAsync: jest.fn().mockResolvedValue('ab'), + processAsync: jest.fn().mockResolvedValue('processedCode'), + }), + {virtual: true}, +); + jest.mock( 'preprocessor-with-sourcemaps', () => ({ @@ -95,6 +126,15 @@ jest.mock( {virtual: true}, ); +jest.mock( + 'async-preprocessor-with-sourcemaps', + () => ({ + getCacheKeyAsync: jest.fn(() => 'ab'), + processAsync: jest.fn(), + }), + {virtual: true}, +); + jest.mock( 'css-preprocessor', () => { @@ -120,6 +160,20 @@ jest.mock('passthrough-preprocessor', () => ({process: jest.fn()}), { // Bad preprocessor jest.mock('skipped-required-props-preprocessor', () => ({}), {virtual: true}); +// Bad preprocessor +jest.mock( + 'skipped-required-props-preprocessor-only-sync', + () => ({process: () => ''}), + {virtual: true}, +); + +// Bad preprocessor +jest.mock( + 'skipped-required-props-preprocessor-only-async', + () => ({processAsync: async () => ''}), + {virtual: true}, +); + // Bad preprocessor jest.mock( 'skipped-required-create-transformer-props-preprocessor', @@ -141,6 +195,16 @@ jest.mock( {virtual: true}, ); +jest.mock( + 'factory-for-async-preprocessor', + () => ({ + createTransformer() { + return {processAsync: jest.fn().mockResolvedValue('code')}; + }, + }), + {virtual: true}, +); + const getCachePath = ( mockFs: Record, config: Config.ProjectConfig, @@ -173,12 +237,14 @@ describe('ScriptTransformer', () => { object = data => Object.assign(Object.create(null), data); mockFs = object({ + '/fruits/avocado.js': ['module.exports = "avocado";'].join('\n'), '/fruits/banana.js': ['module.exports = "banana";'].join('\n'), '/fruits/banana:colon.js': ['module.exports = "bananaColon";'].join('\n'), '/fruits/grapefruit.js': [ 'module.exports = function () { return "grapefruit"; }', ].join('\n'), '/fruits/kiwi.js': ['module.exports = () => "kiwi";'].join('\n'), + '/fruits/mango.js': ['module.exports = () => "mango";'].join('\n'), '/fruits/package.json': ['{"name": "fruits"}'].join('\n'), '/node_modules/react.js': ['module.exports = "react";'].join('\n'), '/styles/App.css': ['root {', ' font-family: Helvetica;', '}'].join( @@ -279,6 +345,50 @@ describe('ScriptTransformer', () => { ); }); + it('transforms a file async properly', async () => { + const scriptTransformer = await createScriptTransformer(config); + const transformedBananaWithCoverage = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + + expect(wrap(transformedBananaWithCoverage.code)).toMatchSnapshot(); + + // no-cache case + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); + + // in-memory cache + const transformedBananaWithCoverageAgain = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + expect(transformedBananaWithCoverageAgain).toBe( + transformedBananaWithCoverage, + ); + + const transformedKiwiWithCoverage = await scriptTransformer.transformAsync( + '/fruits/kiwi.js', + getCoverageOptions({collectCoverage: true}), + ); + expect(wrap(transformedKiwiWithCoverage.code)).toMatchSnapshot(); + + expect(transformedBananaWithCoverage.code).not.toEqual( + transformedKiwiWithCoverage.code, + ); + expect(transformedBananaWithCoverage.code).not.toMatch(/instrumented kiwi/); + + // If we disable coverage, we get a different result. + const transformedKiwiWithoutCoverage = await scriptTransformer.transformAsync( + '/fruits/kiwi.js', + getCoverageOptions({collectCoverage: false}), + ); + + expect(transformedKiwiWithoutCoverage.code).not.toEqual( + transformedKiwiWithCoverage.code, + ); + }); + it("throws an error if `process` doesn't return a string or an object containing `code` key with processed string", async () => { config = { ...config, @@ -314,16 +424,101 @@ describe('ScriptTransformer', () => { }); }); - it("throws an error if `process` isn't defined", async () => { + it("throws an error if `processAsync` doesn't return a promise of string or object containing `code` key with processed string", async () => { + const incorrectReturnValues: Array<[any, string]> = [ + [undefined, '/fruits/banana.js'], + [{a: 'a'}, '/fruits/kiwi.js'], + [[], '/fruits/grapefruit.js'], + ]; + + const correctReturnValues: Array<[any, string]> = [ + ['code', '/fruits/avocado.js'], + [{code: 'code'}, '/fruits/mango.js'], + ]; + + const buildPromise = async ([returnValue, filePath]): Promise => { + const processorName = `passthrough-preprocessor${filePath.replace( + /\.|\//g, + '-', + )}`; + + jest.doMock( + processorName, + () => ({ + processAsync: jest.fn(), + }), + {virtual: true}, + ); + const transformer = require(processorName); + transformer.processAsync.mockResolvedValue(returnValue); + + config = { + ...config, + transform: [ + ...incorrectReturnValues, + ...correctReturnValues, + ].map(([_, filePath]) => [filePath, processorName, {}]), + }; + + const scriptTransformer = await createScriptTransformer(config); + + return scriptTransformer.transformAsync(filePath, getCoverageOptions()); + }; + + const promisesToReject = incorrectReturnValues + .map(buildPromise) + .map(promise => + // Jest must throw error + expect(promise).rejects.toThrow(), + ); + + const promisesToResolve = correctReturnValues + .map(buildPromise) + .map(promise => expect(promise).resolves.toHaveProperty('code')); + + await Promise.all([...promisesToReject, ...promisesToResolve]); + }); + + it('throws an error if neither `process` nor `processAsync is defined', async () => { config = { ...config, transform: [['\\.js$', 'skipped-required-props-preprocessor', {}]], }; await expect(() => createScriptTransformer(config)).rejects.toThrow( - 'Jest: a transform must export a `process` function.', + 'Jest: a transform must export a `process` or `processAsync` function.', + ); + }); + + it("(in sync mode) throws an error if `process` isn't defined", async () => { + config = { + ...config, + transform: [ + ['\\.js$', 'skipped-required-props-preprocessor-only-async', {}], + ], + }; + const scriptTransformer = await createScriptTransformer(config); + expect(() => + scriptTransformer.transformSource('sample.js', '', {instrument: false}), + ).toThrow( + 'Jest: synchronous transformer skipped-required-props-preprocessor-only-async must export a "process" function.', ); }); + it('(in async mode) handles only sync `process`', async () => { + config = { + ...config, + transform: [ + ['\\.js$', 'skipped-required-props-preprocessor-only-sync', {}], + ], + }; + const scriptTransformer = await createScriptTransformer(config); + expect( + await scriptTransformer.transformSourceAsync('sample.js', '', { + instrument: false, + }), + ).toBeDefined(); + }); + it('throws an error if createTransformer returns object without `process` method', async () => { config = { ...config, @@ -336,11 +531,11 @@ describe('ScriptTransformer', () => { ], }; await expect(() => createScriptTransformer(config)).rejects.toThrow( - 'Jest: a transform must export a `process` function.', + 'Jest: a transform must export a `process` or `processAsync` function.', ); }); - it("shouldn't throw error without process method. But with corrent createTransformer method", async () => { + it("shouldn't throw error without process method. But with correct createTransformer method", async () => { config = { ...config, transform: [['\\.js$', 'skipped-process-method-preprocessor', {}]], @@ -351,6 +546,29 @@ describe('ScriptTransformer', () => { ).not.toThrow(); }); + it("in async mode, shouldn't throw if createTransformer returns an preprocessor with `process` or `processAsync`", async () => { + config = { + ...config, + transform: [ + ['async-sample.js', 'factory-for-async-preprocessor', {}], + ['sync-sample.js', 'skipped-process-method-preprocessor', {}], + ], + }; + const scriptTransformer = await createScriptTransformer(config); + await Promise.all([ + expect( + scriptTransformer.transformSourceAsync('async-sample.js', '', { + instrument: false, + }), + ).resolves.toBeDefined(), + expect( + scriptTransformer.transformSourceAsync('sync-sample.js', '', { + instrument: false, + }), + ).resolves.toBeDefined(), + ]); + }); + it('uses the supplied preprocessor', async () => { config = {...config, transform: [['\\.js$', 'test_preprocessor', {}]]}; const scriptTransformer = await createScriptTransformer(config); @@ -371,6 +589,49 @@ describe('ScriptTransformer', () => { expect(wrap(res2.code)).toMatchSnapshot(); }); + it('in async mode, uses the supplied preprocessor', async () => { + config = {...config, transform: [['\\.js$', 'test_preprocessor', {}]]}; + const scriptTransformer = await createScriptTransformer(config); + const res1 = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + expect(require('test_preprocessor').getCacheKey).toBeCalled(); + + expect(wrap(res1.code)).toMatchSnapshot(); + + const res2 = await scriptTransformer.transformAsync( + '/node_modules/react.js', + getCoverageOptions(), + ); + // ignores preprocessor + expect(wrap(res2.code)).toMatchSnapshot(); + }); + + it('in async mode, uses the supplied async preprocessor', async () => { + config = { + ...config, + transform: [['\\.js$', 'test_async_preprocessor', {}]], + }; + const scriptTransformer = await createScriptTransformer(config); + const res1 = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + expect(require('test_async_preprocessor').getCacheKeyAsync).toBeCalled(); + + expect(wrap(res1.code)).toMatchSnapshot(); + + const res2 = await scriptTransformer.transformAsync( + '/node_modules/react.js', + getCoverageOptions(), + ); + // ignores preprocessor + expect(wrap(res2.code)).toMatchSnapshot(); + }); + it('uses multiple preprocessors', async () => { config = { ...config, @@ -403,6 +664,38 @@ describe('ScriptTransformer', () => { expect(wrap(res3.code)).toMatchSnapshot(); }); + it('uses mixture of sync/async preprocessors', async () => { + config = { + ...config, + transform: [ + ['\\.js$', 'test_async_preprocessor', {}], + ['\\.css$', 'css-preprocessor', {}], + ], + }; + const scriptTransformer = await createScriptTransformer(config); + + const res1 = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + const res2 = await scriptTransformer.transformAsync( + '/styles/App.css', + getCoverageOptions(), + ); + + expect(require('test_async_preprocessor').getCacheKeyAsync).toBeCalled(); + expect(require('css-preprocessor').getCacheKey).toBeCalled(); + expect(wrap(res1.code)).toMatchSnapshot(); + expect(wrap(res2.code)).toMatchSnapshot(); + + const res3 = await scriptTransformer.transformAsync( + '/node_modules/react.js', + getCoverageOptions(), + ); + // ignores preprocessor + expect(wrap(res3.code)).toMatchSnapshot(); + }); + it('writes source map if preprocessor supplies it', async () => { config = { ...config, @@ -433,6 +726,68 @@ describe('ScriptTransformer', () => { }); }); + it('in async mode, writes source map if preprocessor supplies it', async () => { + config = { + ...config, + transform: [['\\.js$', 'preprocessor-with-sourcemaps', {}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + const map = { + mappings: ';AAAA', + version: 3, + }; + + require('preprocessor-with-sourcemaps').process.mockReturnValue({ + code: 'content', + map, + }); + + const result = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + expect(result.sourceMapPath).toEqual(expect.any(String)); + const mapStr = JSON.stringify(map); + expect(writeFileAtomic.sync).toBeCalledTimes(2); + expect(writeFileAtomic.sync).toBeCalledWith(result.sourceMapPath, mapStr, { + encoding: 'utf8', + fsync: false, + }); + }); + + it('in async mode, writes source map if async preprocessor supplies it', async () => { + config = { + ...config, + transform: [['\\.js$', 'async-preprocessor-with-sourcemaps', {}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + const map = { + mappings: ';AAAA', + version: 3, + }; + + require('async-preprocessor-with-sourcemaps').processAsync.mockResolvedValue( + { + code: 'content', + map, + }, + ); + + const result = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + expect(result.sourceMapPath).toEqual(expect.any(String)); + const mapStr = JSON.stringify(map); + expect(writeFileAtomic.sync).toBeCalledTimes(2); + expect(writeFileAtomic.sync).toBeCalledWith(result.sourceMapPath, mapStr, { + encoding: 'utf8', + fsync: false, + }); + }); + it('writes source map if preprocessor inlines it', async () => { config = { ...config, @@ -465,10 +820,7 @@ describe('ScriptTransformer', () => { ); }); - it('warns of unparseable inlined source maps from the preprocessor', async () => { - const warn = console.warn; - console.warn = jest.fn(); - + it('in async mode, writes source map if preprocessor inlines it', async () => { config = { ...config, transform: [['\\.js$', 'preprocessor-with-sourcemaps', {}]], @@ -480,15 +832,156 @@ describe('ScriptTransformer', () => { version: 3, }); - // Cut off the inlined map prematurely with slice so the JSON ends abruptly const content = 'var x = 1;\n' + '//# sourceMappingURL=data:application/json;base64,' + - Buffer.from(sourceMap).toString('base64').slice(0, 16); + Buffer.from(sourceMap).toString('base64'); require('preprocessor-with-sourcemaps').process.mockReturnValue(content); - const result = scriptTransformer.transform( + const result = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + expect(result.sourceMapPath).toEqual(expect.any(String)); + expect(writeFileAtomic.sync).toBeCalledTimes(2); + expect(writeFileAtomic.sync).toBeCalledWith( + result.sourceMapPath, + sourceMap, + {encoding: 'utf8', fsync: false}, + ); + }); + + it('writes source map if async preprocessor inlines it', async () => { + config = { + ...config, + transform: [['\\.js$', 'async-preprocessor-with-sourcemaps', {}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + const sourceMap = JSON.stringify({ + mappings: 'AAAA,IAAM,CAAC,GAAW,CAAC,CAAC', + version: 3, + }); + + const content = + 'var x = 1;\n' + + '//# sourceMappingURL=data:application/json;base64,' + + Buffer.from(sourceMap).toString('base64'); + + require('async-preprocessor-with-sourcemaps').processAsync.mockResolvedValue( + content, + ); + + const result = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + expect(result.sourceMapPath).toEqual(expect.any(String)); + expect(writeFileAtomic.sync).toBeCalledTimes(2); + expect(writeFileAtomic.sync).toBeCalledWith( + result.sourceMapPath, + sourceMap, + {encoding: 'utf8', fsync: false}, + ); + }); + + it('warns of unparseable inlined source maps from the preprocessor', async () => { + const warn = console.warn; + console.warn = jest.fn(); + + config = { + ...config, + transform: [['\\.js$', 'preprocessor-with-sourcemaps', {}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + const sourceMap = JSON.stringify({ + mappings: 'AAAA,IAAM,CAAC,GAAW,CAAC,CAAC', + version: 3, + }); + + // Cut off the inlined map prematurely with slice so the JSON ends abruptly + const content = + 'var x = 1;\n' + + '//# sourceMappingURL=data:application/json;base64,' + + Buffer.from(sourceMap).toString('base64').slice(0, 16); + + require('preprocessor-with-sourcemaps').process.mockReturnValue(content); + + const result = scriptTransformer.transform( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + expect(result.sourceMapPath).toBeNull(); + expect(writeFileAtomic.sync).toBeCalledTimes(1); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(wrap(console.warn.mock.calls[0][0])).toMatchSnapshot(); + console.warn = warn; + }); + + it('in async mode, warns of unparseable inlined source maps from the preprocessor', async () => { + const warn = console.warn; + console.warn = jest.fn(); + + config = { + ...config, + transform: [['\\.js$', 'preprocessor-with-sourcemaps', {}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + const sourceMap = JSON.stringify({ + mappings: 'AAAA,IAAM,CAAC,GAAW,CAAC,CAAC', + version: 3, + }); + + // Cut off the inlined map prematurely with slice so the JSON ends abruptly + const content = + 'var x = 1;\n' + + '//# sourceMappingURL=data:application/json;base64,' + + Buffer.from(sourceMap).toString('base64').slice(0, 16); + + require('preprocessor-with-sourcemaps').process.mockReturnValue(content); + + const result = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + expect(result.sourceMapPath).toBeNull(); + expect(writeFileAtomic.sync).toBeCalledTimes(1); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(wrap(console.warn.mock.calls[0][0])).toMatchSnapshot(); + console.warn = warn; + }); + + it('warns of unparseable inlined source maps from the async preprocessor', async () => { + const warn = console.warn; + console.warn = jest.fn(); + + config = { + ...config, + transform: [['\\.js$', 'async-preprocessor-with-sourcemaps', {}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + const sourceMap = JSON.stringify({ + mappings: 'AAAA,IAAM,CAAC,GAAW,CAAC,CAAC', + version: 3, + }); + + // Cut off the inlined map prematurely with slice so the JSON ends abruptly + const content = + 'var x = 1;\n' + + '//# sourceMappingURL=data:application/json;base64,' + + Buffer.from(sourceMap).toString('base64').slice(0, 16); + + require('async-preprocessor-with-sourcemaps').processAsync.mockResolvedValue( + content, + ); + + const result = await scriptTransformer.transformAsync( '/fruits/banana.js', getCoverageOptions({collectCoverage: true}), ); @@ -500,6 +993,7 @@ describe('ScriptTransformer', () => { console.warn = warn; }); + // this duplicates with 'writes source map if preprocessor supplies it' it('writes source maps if given by the transformer', async () => { config = { ...config, @@ -553,6 +1047,48 @@ describe('ScriptTransformer', () => { expect(writeFileAtomic.sync).toHaveBeenCalledTimes(1); }); + it('in async mode, does not write source map if not given by the transformer', async () => { + config = { + ...config, + transform: [['\\.js$', 'preprocessor-with-sourcemaps', {}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + require('preprocessor-with-sourcemaps').process.mockReturnValue({ + code: 'content', + map: null, + }); + + const result = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + expect(result.sourceMapPath).toBeFalsy(); + expect(writeFileAtomic.sync).toHaveBeenCalledTimes(1); + }); + + it('does not write source map if not given by the async preprocessor', async () => { + config = { + ...config, + transform: [['\\.js$', 'async-preprocessor-with-sourcemaps', {}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + require('async-preprocessor-with-sourcemaps').processAsync.mockResolvedValue( + { + code: 'content', + map: null, + }, + ); + + const result = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + expect(result.sourceMapPath).toBeFalsy(); + expect(writeFileAtomic.sync).toHaveBeenCalledTimes(1); + }); + it('should write a source map for the instrumented file when transformed', async () => { const transformerConfig: Config.ProjectConfig = { ...config, @@ -598,27 +1134,36 @@ describe('ScriptTransformer', () => { expect(result.code).toContain('//# sourceMappingURL'); }); - it('should write a source map for the instrumented file when not transformed', async () => { - const scriptTransformer = await createScriptTransformer(config); + it('in async mode, should write a source map for the instrumented file when transformed', async () => { + const transformerConfig: Config.ProjectConfig = { + ...config, + transform: [['\\.js$', 'preprocessor-with-sourcemaps', {}]], + }; + const scriptTransformer = await createScriptTransformer(transformerConfig); + + const map = { + mappings: ';AAAA', + version: 3, + }; // A map from the original source to the instrumented output /* eslint-disable sort-keys */ const instrumentedCodeMap = { version: 3, sources: ['banana.js'], - names: ['module', 'exports'], + names: ['content'], mappings: - ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeY;;;;;;;;;;AAfZA,MAAM,CAACC,OAAP,GAAiB,QAAjB', - sourcesContent: ['module.exports = "banana";'], + ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeY;;;;;;;;;;AAfZA,OAAO', + sourcesContent: ['content'], }; /* eslint-enable */ require('preprocessor-with-sourcemaps').process.mockReturnValue({ code: 'content', - map: null, + map, }); - const result = scriptTransformer.transform( + const result = await scriptTransformer.transformAsync( '/fruits/banana.js', getCoverageOptions({collectCoverage: true}), ); @@ -634,46 +1179,237 @@ describe('ScriptTransformer', () => { expect(result.code).toContain('//# sourceMappingURL'); }); - it('passes expected transform options to getCacheKey', async () => { - config = { + it('should write a source map for the instrumented file when async transformed', async () => { + const transformerConfig: Config.ProjectConfig = { ...config, - transform: [['\\.js$', 'test_preprocessor', {configKey: 'configValue'}]], + transform: [['\\.js$', 'async-preprocessor-with-sourcemaps', {}]], }; - const scriptTransformer = await createScriptTransformer(config); + const scriptTransformer = await createScriptTransformer(transformerConfig); - scriptTransformer.transform( + const map = { + mappings: ';AAAA', + version: 3, + }; + + // A map from the original source to the instrumented output + /* eslint-disable sort-keys */ + const instrumentedCodeMap = { + version: 3, + sources: ['banana.js'], + names: ['content'], + mappings: + ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeY;;;;;;;;;;AAfZA,OAAO', + sourcesContent: ['content'], + }; + /* eslint-enable */ + + require('async-preprocessor-with-sourcemaps').processAsync.mockResolvedValue( + { + code: 'content', + map, + }, + ); + + const result = await scriptTransformer.transformAsync( '/fruits/banana.js', getCoverageOptions({collectCoverage: true}), ); + expect(result.sourceMapPath).toEqual(expect.any(String)); + expect(writeFileAtomic.sync).toBeCalledTimes(2); + expect(writeFileAtomic.sync).toBeCalledWith( + result.sourceMapPath, + JSON.stringify(instrumentedCodeMap), + expect.anything(), + ); - const {getCacheKey} = require('test_preprocessor'); - expect(getCacheKey).toMatchSnapshot(); + // Inline source map allows debugging of original source when running instrumented code + expect(result.code).toContain('//# sourceMappingURL'); }); - it('creates transformer with config', async () => { - const transformerConfig = {}; - config = Object.assign(config, { - transform: [['\\.js$', 'configureable-preprocessor', transformerConfig]], - }); + it('should write a source map for the instrumented file when not transformed', async () => { const scriptTransformer = await createScriptTransformer(config); - scriptTransformer.transform('/fruits/banana.js', {}); - expect( - require('configureable-preprocessor').createTransformer, - ).toHaveBeenCalledWith(transformerConfig); - }); - - it('reads values from the cache', async () => { - const transformConfig: Config.ProjectConfig = { - ...config, - transform: [['\\.js$', 'test_preprocessor', {}]], + // A map from the original source to the instrumented output + /* eslint-disable sort-keys */ + const instrumentedCodeMap = { + version: 3, + sources: ['banana.js'], + names: ['module', 'exports'], + mappings: + ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeY;;;;;;;;;;AAfZA,MAAM,CAACC,OAAP,GAAiB,QAAjB', + sourcesContent: ['module.exports = "banana";'], }; - let scriptTransformer = await createScriptTransformer(transformConfig); - scriptTransformer.transform('/fruits/banana.js', getCoverageOptions()); - - const cachePath = getCachePath(mockFs, config); - expect(writeFileAtomic.sync).toBeCalled(); - expect(writeFileAtomic.sync.mock.calls[0][0]).toBe(cachePath); + /* eslint-enable */ + + require('preprocessor-with-sourcemaps').process.mockReturnValue({ + code: 'content', + map: null, + }); + + const result = scriptTransformer.transform( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + expect(result.sourceMapPath).toEqual(expect.any(String)); + expect(writeFileAtomic.sync).toBeCalledTimes(2); + expect(writeFileAtomic.sync).toBeCalledWith( + result.sourceMapPath, + JSON.stringify(instrumentedCodeMap), + expect.anything(), + ); + + // Inline source map allows debugging of original source when running instrumented code + expect(result.code).toContain('//# sourceMappingURL'); + }); + + it('in async mode, should write a source map for the instrumented file when not transformed', async () => { + const scriptTransformer = await createScriptTransformer(config); + + // A map from the original source to the instrumented output + /* eslint-disable sort-keys */ + const instrumentedCodeMap = { + version: 3, + sources: ['banana.js'], + names: ['module', 'exports'], + mappings: + ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeY;;;;;;;;;;AAfZA,MAAM,CAACC,OAAP,GAAiB,QAAjB', + sourcesContent: ['module.exports = "banana";'], + }; + /* eslint-enable */ + + require('preprocessor-with-sourcemaps').process.mockReturnValue({ + code: 'content', + map: null, + }); + + const result = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + expect(result.sourceMapPath).toEqual(expect.any(String)); + expect(writeFileAtomic.sync).toBeCalledTimes(2); + expect(writeFileAtomic.sync).toBeCalledWith( + result.sourceMapPath, + JSON.stringify(instrumentedCodeMap), + expect.anything(), + ); + + // Inline source map allows debugging of original source when running instrumented code + expect(result.code).toContain('//# sourceMappingURL'); + }); + + it('should write a source map for the instrumented file when not transformed by async preprocessor', async () => { + const scriptTransformer = await createScriptTransformer(config); + + // A map from the original source to the instrumented output + /* eslint-disable sort-keys */ + const instrumentedCodeMap = { + version: 3, + sources: ['banana.js'], + names: ['module', 'exports'], + mappings: + ';;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeY;;;;;;;;;;AAfZA,MAAM,CAACC,OAAP,GAAiB,QAAjB', + sourcesContent: ['module.exports = "banana";'], + }; + /* eslint-enable */ + + require('async-preprocessor-with-sourcemaps').processAsync.mockResolvedValue( + { + code: 'content', + map: null, + }, + ); + + const result = await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + expect(result.sourceMapPath).toEqual(expect.any(String)); + expect(writeFileAtomic.sync).toBeCalledTimes(2); + expect(writeFileAtomic.sync).toBeCalledWith( + result.sourceMapPath, + JSON.stringify(instrumentedCodeMap), + expect.anything(), + ); + + // Inline source map allows debugging of original source when running instrumented code + expect(result.code).toContain('//# sourceMappingURL'); + }); + + it('passes expected transform options to getCacheKey', async () => { + config = { + ...config, + transform: [['\\.js$', 'test_preprocessor', {configKey: 'configValue'}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + scriptTransformer.transform( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + + const {getCacheKey} = require('test_preprocessor'); + expect(getCacheKey).toMatchSnapshot(); + }); + + it('in async mode, passes expected transform options to getCacheKey', async () => { + config = { + ...config, + transform: [['\\.js$', 'test_preprocessor', {configKey: 'configValue'}]], + }; + const scriptTransformer = await createScriptTransformer(config); + + await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + + const {getCacheKey} = require('test_preprocessor'); + expect(getCacheKey).toMatchSnapshot(); + }); + + it('passes expected transform options to getCacheKeyAsync', async () => { + config = { + ...config, + transform: [ + ['\\.js$', 'test_async_preprocessor', {configKey: 'configValue'}], + ], + }; + const scriptTransformer = await createScriptTransformer(config); + + await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions({collectCoverage: true}), + ); + + const {getCacheKeyAsync} = require('test_async_preprocessor'); + expect(getCacheKeyAsync).toMatchSnapshot(); + }); + + it('creates transformer with config', async () => { + const transformerConfig = {}; + config = Object.assign(config, { + transform: [['\\.js$', 'configureable-preprocessor', transformerConfig]], + }); + const scriptTransformer = await createScriptTransformer(config); + + scriptTransformer.transform('/fruits/banana.js', {}); + expect( + require('configureable-preprocessor').createTransformer, + ).toHaveBeenCalledWith(transformerConfig); + }); + + it('reads values from the cache', async () => { + const transformConfig: Config.ProjectConfig = { + ...config, + transform: [['\\.js$', 'test_preprocessor', {}]], + }; + let scriptTransformer = await createScriptTransformer(transformConfig); + scriptTransformer.transform('/fruits/banana.js', getCoverageOptions()); + + const cachePath = getCachePath(mockFs, config); + expect(writeFileAtomic.sync).toBeCalled(); + expect(writeFileAtomic.sync.mock.calls[0][0]).toBe(cachePath); // Cache the state in `mockFsCopy` const mockFsCopy = mockFs; @@ -704,6 +1440,106 @@ describe('ScriptTransformer', () => { expect(writeFileAtomic.sync).toBeCalled(); }); + it('in async mode, reads values from the cache', async () => { + const transformConfig: Config.ProjectConfig = { + ...config, + transform: [['\\.js$', 'test_preprocessor', {}]], + }; + let scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + const cachePath = getCachePath(mockFs, config); + expect(writeFileAtomic.sync).toBeCalled(); + expect(writeFileAtomic.sync.mock.calls[0][0]).toBe(cachePath); + + // Cache the state in `mockFsCopy` + const mockFsCopy = mockFs; + jest.resetModules(); + reset(); + + // Restore the cached fs + mockFs = mockFsCopy; + scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + expect(fs.readFileSync).toHaveBeenCalledTimes(2); + expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); + expect(fs.readFileSync).toBeCalledWith(cachePath, 'utf8'); + expect(writeFileAtomic.sync).not.toBeCalled(); + + // Don't read from the cache when `config.cache` is false. + jest.resetModules(); + reset(); + mockFs = mockFsCopy; + transformConfig.cache = false; + scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); + expect(fs.readFileSync).not.toBeCalledWith(cachePath, 'utf8'); + expect(writeFileAtomic.sync).toBeCalled(); + }); + + it('reads values from the cache when using async preprocessor', async () => { + const transformConfig: Config.ProjectConfig = { + ...config, + transform: [['\\.js$', 'test_async_preprocessor', {}]], + }; + let scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + const cachePath = getCachePath(mockFs, config); + expect(writeFileAtomic.sync).toBeCalled(); + expect(writeFileAtomic.sync.mock.calls[0][0]).toBe(cachePath); + + // Cache the state in `mockFsCopy` + const mockFsCopy = mockFs; + jest.resetModules(); + reset(); + + // Restore the cached fs + mockFs = mockFsCopy; + scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + expect(fs.readFileSync).toHaveBeenCalledTimes(2); + expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); + expect(fs.readFileSync).toBeCalledWith(cachePath, 'utf8'); + expect(writeFileAtomic.sync).not.toBeCalled(); + + // Don't read from the cache when `config.cache` is false. + jest.resetModules(); + reset(); + mockFs = mockFsCopy; + transformConfig.cache = false; + scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); + expect(fs.readFileSync).not.toBeCalledWith(cachePath, 'utf8'); + expect(writeFileAtomic.sync).toBeCalled(); + }); + it('reads values from the cache when the file contains colons', async () => { const transformConfig: Config.ProjectConfig = { ...config, @@ -735,6 +1571,68 @@ describe('ScriptTransformer', () => { expect(writeFileAtomic.sync).not.toBeCalled(); }); + it('in async mode, reads values from the cache when the file contains colons', async () => { + const transformConfig: Config.ProjectConfig = { + ...config, + transform: [['\\.js$', 'test_preprocessor', {}]], + }; + let scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync( + '/fruits/banana:colon.js', + getCoverageOptions(), + ); + + const cachePath = getCachePath(mockFs, config); + expect(writeFileAtomic.sync).toBeCalled(); + expect(writeFileAtomic.sync.mock.calls[0][0]).toBe(cachePath); + + // Cache the state in `mockFsCopy` + const mockFsCopy = mockFs; + jest.resetModules(); + reset(); + + // Restore the cached fs + mockFs = mockFsCopy; + scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync('/fruits/banana:colon.js', {}); + + expect(fs.readFileSync).toHaveBeenCalledTimes(2); + expect(fs.readFileSync).toBeCalledWith('/fruits/banana:colon.js', 'utf8'); + expect(fs.readFileSync).toBeCalledWith(cachePath, 'utf8'); + expect(writeFileAtomic.sync).not.toBeCalled(); + }); + + it('with async preprocessor, reads values from the cache when the file contains colons', async () => { + const transformConfig: Config.ProjectConfig = { + ...config, + transform: [['\\.js$', 'test_async_preprocessor', {}]], + }; + let scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync( + '/fruits/banana:colon.js', + getCoverageOptions(), + ); + + const cachePath = getCachePath(mockFs, config); + expect(writeFileAtomic.sync).toBeCalled(); + expect(writeFileAtomic.sync.mock.calls[0][0]).toBe(cachePath); + + // Cache the state in `mockFsCopy` + const mockFsCopy = mockFs; + jest.resetModules(); + reset(); + + // Restore the cached fs + mockFs = mockFsCopy; + scriptTransformer = await createScriptTransformer(transformConfig); + await scriptTransformer.transformAsync('/fruits/banana:colon.js', {}); + + expect(fs.readFileSync).toHaveBeenCalledTimes(2); + expect(fs.readFileSync).toBeCalledWith('/fruits/banana:colon.js', 'utf8'); + expect(fs.readFileSync).toBeCalledWith(cachePath, 'utf8'); + expect(writeFileAtomic.sync).not.toBeCalled(); + }); + it('should reuse the value from in-memory cache which is set by custom transformer', async () => { const cacheFS = new Map(); const testPreprocessor = require('cache_fs_preprocessor'); @@ -760,6 +1658,60 @@ describe('ScriptTransformer', () => { expect(fs.readFileSync).toBeCalledWith(fileName1, 'utf8'); }); + it('in async mode, should reuse the value from in-memory cache which is set by custom preprocessor', async () => { + const cacheFS = new Map(); + const testPreprocessor = require('cache_fs_preprocessor'); + const scriptTransformer = await createScriptTransformer( + { + ...config, + transform: [['\\.js$', 'cache_fs_preprocessor', {}]], + }, + cacheFS, + ); + const fileName1 = '/fruits/banana.js'; + const fileName2 = '/fruits/kiwi.js'; + + await scriptTransformer.transformAsync(fileName1, getCoverageOptions()); + + cacheFS.set(fileName2, 'foo'); + + await scriptTransformer.transformAsync(fileName2, getCoverageOptions()); + + expect(testPreprocessor.getCacheKey.mock.calls[0][2].cacheFS).toBeDefined(); + expect(testPreprocessor.process.mock.calls[0][2].cacheFS).toBeDefined(); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + expect(fs.readFileSync).toBeCalledWith(fileName1, 'utf8'); + }); + + it('should reuse the value from in-memory cache which is set by custom async preprocessor', async () => { + const cacheFS = new Map(); + const testPreprocessor = require('cache_fs_async_preprocessor'); + const scriptTransformer = await createScriptTransformer( + { + ...config, + transform: [['\\.js$', 'cache_fs_async_preprocessor', {}]], + }, + cacheFS, + ); + const fileName1 = '/fruits/banana.js'; + const fileName2 = '/fruits/kiwi.js'; + + await scriptTransformer.transformAsync(fileName1, getCoverageOptions()); + + cacheFS.set(fileName2, 'foo'); + + await scriptTransformer.transformAsync(fileName2, getCoverageOptions()); + + expect( + testPreprocessor.getCacheKeyAsync.mock.calls[0][2].cacheFS, + ).toBeDefined(); + expect( + testPreprocessor.processAsync.mock.calls[0][2].cacheFS, + ).toBeDefined(); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + expect(fs.readFileSync).toBeCalledWith(fileName1, 'utf8'); + }); + it('does not reuse the in-memory cache between different projects', async () => { const scriptTransformer = await createScriptTransformer({ ...config, @@ -782,6 +1734,71 @@ describe('ScriptTransformer', () => { expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); }); + it('async mode does not reuse the in-memory cache between different projects', async () => { + const scriptTransformer = await createScriptTransformer({ + ...config, + transform: [['\\.js$', 'test_preprocessor', {}]], + }); + + await scriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + const anotherScriptTransformer = await createScriptTransformer({ + ...config, + transform: [['\\.js$', 'css-preprocessor', {}]], + }); + + await anotherScriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + expect(fs.readFileSync).toHaveBeenCalledTimes(2); + expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); + }); + + it('regardless of sync/async, does not reuse the in-memory cache between different projects', async () => { + const scriptTransformer = await createScriptTransformer({ + ...config, + transform: [['\\.js$', 'test_preprocessor', {}]], + }); + + scriptTransformer.transform('/fruits/banana.js', getCoverageOptions()); + + const anotherScriptTransformer = await createScriptTransformer({ + ...config, + transform: [['\\.js$', 'css-preprocessor', {}]], + }); + + await anotherScriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + const yetAnotherScriptTransformer = await createScriptTransformer({ + ...config, + transform: [['\\.js$', 'test_preprocessor', {}]], + }); + yetAnotherScriptTransformer.transform( + '/fruits/banana.js', + getCoverageOptions(), + ); + + const fruityScriptTransformer = await createScriptTransformer({ + ...config, + transform: [['\\.js$', 'test_async_preprocessor', {}]], + }); + await fruityScriptTransformer.transformAsync( + '/fruits/banana.js', + getCoverageOptions(), + ); + + expect(fs.readFileSync).toHaveBeenCalledTimes(4); + expect(fs.readFileSync).toBeCalledWith('/fruits/banana.js', 'utf8'); + }); + it('preload transformer when using `createScriptTransformer`', async () => { const scriptTransformer = await createScriptTransformer({ ...config, diff --git a/packages/jest-transform/src/__tests__/__snapshots__/ScriptTransformer.test.ts.snap b/packages/jest-transform/src/__tests__/__snapshots__/ScriptTransformer.test.ts.snap index 453952e23747..30afd6905c65 100644 --- a/packages/jest-transform/src/__tests__/__snapshots__/ScriptTransformer.test.ts.snap +++ b/packages/jest-transform/src/__tests__/__snapshots__/ScriptTransformer.test.ts.snap @@ -1,5 +1,132 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`ScriptTransformer in async mode, passes expected transform options to getCacheKey 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "module.exports = \\"banana\\";", + "/fruits/banana.js", + Object { + "cacheFS": Map { + "/fruits/banana.js" => "module.exports = \\"banana\\";", + }, + "collectCoverage": true, + "collectCoverageFrom": Array [], + "collectCoverageOnlyFrom": undefined, + "config": Object { + "automock": false, + "cache": true, + "cacheDirectory": "/cache/", + "clearMocks": false, + "coveragePathIgnorePatterns": Array [], + "cwd": "/test_root_dir/", + "detectLeaks": false, + "detectOpenHandles": false, + "displayName": undefined, + "errorOnDeprecated": false, + "extensionsToTreatAsEsm": Array [], + "extraGlobals": Array [], + "filter": undefined, + "forceCoverageMatch": Array [], + "globalSetup": undefined, + "globalTeardown": undefined, + "globals": Object {}, + "haste": Object {}, + "injectGlobals": true, + "moduleDirectories": Array [], + "moduleFileExtensions": Array [ + "js", + ], + "moduleLoader": "/test_module_loader_path", + "moduleNameMapper": Array [], + "modulePathIgnorePatterns": Array [], + "modulePaths": Array [], + "name": "test", + "prettierPath": "prettier", + "resetMocks": false, + "resetModules": false, + "resolver": undefined, + "restoreMocks": false, + "rootDir": "/", + "roots": Array [], + "runner": "jest-runner", + "setupFiles": Array [], + "setupFilesAfterEnv": Array [], + "skipFilter": false, + "skipNodeResolution": false, + "slowTestThreshold": 5, + "snapshotResolver": undefined, + "snapshotSerializers": Array [], + "testEnvironment": "node", + "testEnvironmentOptions": Object {}, + "testLocationInResults": false, + "testMatch": Array [], + "testPathIgnorePatterns": Array [], + "testRegex": Array [ + "\\\\.test\\\\.js$", + ], + "testRunner": "jest-circus/runner", + "testURL": "http://localhost", + "timers": "real", + "transform": Array [ + Array [ + "\\\\.js$", + "test_preprocessor", + Object { + "configKey": "configValue", + }, + ], + ], + "transformIgnorePatterns": Array [ + "/node_modules/", + ], + "unmockedModulePathPatterns": undefined, + "watchPathIgnorePatterns": Array [], + }, + "configString": "{\\"automock\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extensionsToTreatAsEsm\\":[],\\"extraGlobals\\":[],\\"forceCoverageMatch\\":[],\\"globals\\":{},\\"haste\\":{},\\"injectGlobals\\":true,\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"slowTestThreshold\\":5,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-circus/runner\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"\\\\\\\\.js$\\",\\"test_preprocessor\\",{\\"configKey\\":\\"configValue\\"}]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"watchPathIgnorePatterns\\":[]}", + "coverageProvider": "babel", + "instrument": true, + "supportsDynamicImport": false, + "supportsExportNamespaceFrom": false, + "supportsStaticESM": false, + "supportsTopLevelAwait": false, + "transformerConfig": Object { + "configKey": "configValue", + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": "ab", + }, + ], +} +`; + +exports[`ScriptTransformer in async mode, uses the supplied async preprocessor 1`] = ` +const TRANSFORMED = { + filename: '/fruits/banana.js', + script: 'module.exports = "banana";', + config: '{"collectCoverage":false,"collectCoverageFrom":[],"coverageProvider":"babel","supportsDynamicImport":false,"supportsExportNamespaceFrom":false,"supportsStaticESM":false,"supportsTopLevelAwait":false,"instrument":false,"cacheFS":{},"config":{"automock":false,"cache":true,"cacheDirectory":"/cache/","clearMocks":false,"coveragePathIgnorePatterns":[],"cwd":"/test_root_dir/","detectLeaks":false,"detectOpenHandles":false,"errorOnDeprecated":false,"extensionsToTreatAsEsm":[],"extraGlobals":[],"forceCoverageMatch":[],"globals":{},"haste":{},"injectGlobals":true,"moduleDirectories":[],"moduleFileExtensions":["js"],"moduleLoader":"/test_module_loader_path","moduleNameMapper":[],"modulePathIgnorePatterns":[],"modulePaths":[],"name":"test","prettierPath":"prettier","resetMocks":false,"resetModules":false,"restoreMocks":false,"rootDir":"/","roots":[],"runner":"jest-runner","setupFiles":[],"setupFilesAfterEnv":[],"skipFilter":false,"skipNodeResolution":false,"slowTestThreshold":5,"snapshotSerializers":[],"testEnvironment":"node","testEnvironmentOptions":{},"testLocationInResults":false,"testMatch":[],"testPathIgnorePatterns":[],"testRegex":["\\\\.test\\\\.js$"],"testRunner":"jest-circus/runner","testURL":"http://localhost","timers":"real","transform":[["\\\\.js$","test_async_preprocessor",{}]],"transformIgnorePatterns":["/node_modules/"],"watchPathIgnorePatterns":[]},"configString":"{\\"automock\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extensionsToTreatAsEsm\\":[],\\"extraGlobals\\":[],\\"forceCoverageMatch\\":[],\\"globals\\":{},\\"haste\\":{},\\"injectGlobals\\":true,\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"slowTestThreshold\\":5,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-circus/runner\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"\\\\\\\\.js$\\",\\"test_async_preprocessor\\",{}]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"watchPathIgnorePatterns\\":[]}","transformerConfig":{}}', +}; +`; + +exports[`ScriptTransformer in async mode, uses the supplied async preprocessor 2`] = `module.exports = "react";`; + +exports[`ScriptTransformer in async mode, uses the supplied preprocessor 1`] = ` +const TRANSFORMED = { + filename: '/fruits/banana.js', + script: 'module.exports = "banana";', + config: '{"collectCoverage":false,"collectCoverageFrom":[],"coverageProvider":"babel","supportsDynamicImport":false,"supportsExportNamespaceFrom":false,"supportsStaticESM":false,"supportsTopLevelAwait":false,"instrument":false,"cacheFS":{},"config":{"automock":false,"cache":true,"cacheDirectory":"/cache/","clearMocks":false,"coveragePathIgnorePatterns":[],"cwd":"/test_root_dir/","detectLeaks":false,"detectOpenHandles":false,"errorOnDeprecated":false,"extensionsToTreatAsEsm":[],"extraGlobals":[],"forceCoverageMatch":[],"globals":{},"haste":{},"injectGlobals":true,"moduleDirectories":[],"moduleFileExtensions":["js"],"moduleLoader":"/test_module_loader_path","moduleNameMapper":[],"modulePathIgnorePatterns":[],"modulePaths":[],"name":"test","prettierPath":"prettier","resetMocks":false,"resetModules":false,"restoreMocks":false,"rootDir":"/","roots":[],"runner":"jest-runner","setupFiles":[],"setupFilesAfterEnv":[],"skipFilter":false,"skipNodeResolution":false,"slowTestThreshold":5,"snapshotSerializers":[],"testEnvironment":"node","testEnvironmentOptions":{},"testLocationInResults":false,"testMatch":[],"testPathIgnorePatterns":[],"testRegex":["\\\\.test\\\\.js$"],"testRunner":"jest-circus/runner","testURL":"http://localhost","timers":"real","transform":[["\\\\.js$","test_preprocessor",{}]],"transformIgnorePatterns":["/node_modules/"],"watchPathIgnorePatterns":[]},"configString":"{\\"automock\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extensionsToTreatAsEsm\\":[],\\"extraGlobals\\":[],\\"forceCoverageMatch\\":[],\\"globals\\":{},\\"haste\\":{},\\"injectGlobals\\":true,\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"slowTestThreshold\\":5,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-circus/runner\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"\\\\\\\\.js$\\",\\"test_preprocessor\\",{}]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"watchPathIgnorePatterns\\":[]}","transformerConfig":{}}', +}; +`; + +exports[`ScriptTransformer in async mode, uses the supplied preprocessor 2`] = `module.exports = "react";`; + +exports[`ScriptTransformer in async mode, warns of unparseable inlined source maps from the preprocessor 1`] = `jest-transform: The source map produced for the file /fruits/banana.js by preprocessor-with-sourcemaps was invalid. Proceeding without source mapping for that file.`; + exports[`ScriptTransformer passes expected transform options to getCacheKey 1`] = ` [MockFunction] { "calls": Array [ @@ -105,6 +232,263 @@ exports[`ScriptTransformer passes expected transform options to getCacheKey 1`] } `; +exports[`ScriptTransformer passes expected transform options to getCacheKeyAsync 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + "module.exports = \\"banana\\";", + "/fruits/banana.js", + Object { + "cacheFS": Map { + "/fruits/banana.js" => "module.exports = \\"banana\\";", + }, + "collectCoverage": true, + "collectCoverageFrom": Array [], + "collectCoverageOnlyFrom": undefined, + "config": Object { + "automock": false, + "cache": true, + "cacheDirectory": "/cache/", + "clearMocks": false, + "coveragePathIgnorePatterns": Array [], + "cwd": "/test_root_dir/", + "detectLeaks": false, + "detectOpenHandles": false, + "displayName": undefined, + "errorOnDeprecated": false, + "extensionsToTreatAsEsm": Array [], + "extraGlobals": Array [], + "filter": undefined, + "forceCoverageMatch": Array [], + "globalSetup": undefined, + "globalTeardown": undefined, + "globals": Object {}, + "haste": Object {}, + "injectGlobals": true, + "moduleDirectories": Array [], + "moduleFileExtensions": Array [ + "js", + ], + "moduleLoader": "/test_module_loader_path", + "moduleNameMapper": Array [], + "modulePathIgnorePatterns": Array [], + "modulePaths": Array [], + "name": "test", + "prettierPath": "prettier", + "resetMocks": false, + "resetModules": false, + "resolver": undefined, + "restoreMocks": false, + "rootDir": "/", + "roots": Array [], + "runner": "jest-runner", + "setupFiles": Array [], + "setupFilesAfterEnv": Array [], + "skipFilter": false, + "skipNodeResolution": false, + "slowTestThreshold": 5, + "snapshotResolver": undefined, + "snapshotSerializers": Array [], + "testEnvironment": "node", + "testEnvironmentOptions": Object {}, + "testLocationInResults": false, + "testMatch": Array [], + "testPathIgnorePatterns": Array [], + "testRegex": Array [ + "\\\\.test\\\\.js$", + ], + "testRunner": "jest-circus/runner", + "testURL": "http://localhost", + "timers": "real", + "transform": Array [ + Array [ + "\\\\.js$", + "test_async_preprocessor", + Object { + "configKey": "configValue", + }, + ], + ], + "transformIgnorePatterns": Array [ + "/node_modules/", + ], + "unmockedModulePathPatterns": undefined, + "watchPathIgnorePatterns": Array [], + }, + "configString": "{\\"automock\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extensionsToTreatAsEsm\\":[],\\"extraGlobals\\":[],\\"forceCoverageMatch\\":[],\\"globals\\":{},\\"haste\\":{},\\"injectGlobals\\":true,\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"slowTestThreshold\\":5,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-circus/runner\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"\\\\\\\\.js$\\",\\"test_async_preprocessor\\",{\\"configKey\\":\\"configValue\\"}]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"watchPathIgnorePatterns\\":[]}", + "coverageProvider": "babel", + "instrument": true, + "supportsDynamicImport": false, + "supportsExportNamespaceFrom": false, + "supportsStaticESM": false, + "supportsTopLevelAwait": false, + "transformerConfig": Object { + "configKey": "configValue", + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`; + +exports[`ScriptTransformer transforms a file async properly 1`] = ` +/* istanbul ignore next */ +function cov_25u22311x4() { + var path = "/fruits/banana.js"; + var hash = "3f8e915bed83285455a8a16aa04dc0cf5242d755"; + var global = new Function("return this")(); + var gcv = "__coverage__"; + var coverageData = { + path: "/fruits/banana.js", + statementMap: { + "0": { + start: { + line: 1, + column: 0 + }, + end: { + line: 1, + column: 26 + } + } + }, + fnMap: {}, + branchMap: {}, + s: { + "0": 0 + }, + f: {}, + b: {}, + inputSourceMap: null, + _coverageSchema: "1a1c01bbd47fc00a2c39e90264f33305004495a9", + hash: "3f8e915bed83285455a8a16aa04dc0cf5242d755" + }; + var coverage = global[gcv] || (global[gcv] = {}); + + if (!coverage[path] || coverage[path].hash !== hash) { + coverage[path] = coverageData; + } + + var actualCoverage = coverage[path]; + { + // @ts-ignore + cov_25u22311x4 = function () { + return actualCoverage; + }; + } + return actualCoverage; +} + +cov_25u22311x4(); +cov_25u22311x4().s[0]++; +module.exports = "banana"; +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImJhbmFuYS5qcyJdLCJuYW1lcyI6WyJtb2R1bGUiLCJleHBvcnRzIl0sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7QUFlWTs7Ozs7Ozs7OztBQWZaQSxNQUFNLENBQUNDLE9BQVAsR0FBaUIsUUFBakIiLCJzb3VyY2VzQ29udGVudCI6WyJtb2R1bGUuZXhwb3J0cyA9IFwiYmFuYW5hXCI7Il19 +`; + +exports[`ScriptTransformer transforms a file async properly 2`] = ` +/* istanbul ignore next */ +function cov_23yvu8etmu() { + var path = "/fruits/kiwi.js"; + var hash = "8b5afd38d79008f13ebc229b89ef82b12ee9447a"; + var global = new Function("return this")(); + var gcv = "__coverage__"; + var 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: {}, + inputSourceMap: null, + _coverageSchema: "1a1c01bbd47fc00a2c39e90264f33305004495a9", + hash: "8b5afd38d79008f13ebc229b89ef82b12ee9447a" + }; + var coverage = global[gcv] || (global[gcv] = {}); + + if (!coverage[path] || coverage[path].hash !== hash) { + coverage[path] = coverageData; + } + + var actualCoverage = coverage[path]; + { + // @ts-ignore + cov_23yvu8etmu = function () { + return actualCoverage; + }; + } + return actualCoverage; +} + +cov_23yvu8etmu(); +cov_23yvu8etmu().s[0]++; + +module.exports = () => { + /* istanbul ignore next */ + cov_23yvu8etmu().f[0]++; + cov_23yvu8etmu().s[1]++; + return "kiwi"; +}; +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImtpd2kuanMiXSwibmFtZXMiOlsibW9kdWxlIiwiZXhwb3J0cyJdLCJtYXBwaW5ncyI6Ijs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7QUFlWTs7Ozs7Ozs7Ozs7QUFmWkEsTUFBTSxDQUFDQyxPQUFQLEdBQWlCLE1BQU07QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFNLENBQTdCIiwic291cmNlc0NvbnRlbnQiOlsibW9kdWxlLmV4cG9ydHMgPSAoKSA9PiBcImtpd2lcIjsiXX0= +`; + exports[`ScriptTransformer transforms a file properly 1`] = ` /* istanbul ignore next */ function cov_25u22311x4() { @@ -257,6 +641,23 @@ module.exports = () => { //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbImtpd2kuanMiXSwibmFtZXMiOlsibW9kdWxlIiwiZXhwb3J0cyJdLCJtYXBwaW5ncyI6Ijs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7QUFlWTs7Ozs7Ozs7Ozs7QUFmWkEsTUFBTSxDQUFDQyxPQUFQLEdBQWlCLE1BQU07QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFNLENBQTdCIiwic291cmNlc0NvbnRlbnQiOlsibW9kdWxlLmV4cG9ydHMgPSAoKSA9PiBcImtpd2lcIjsiXX0= `; +exports[`ScriptTransformer uses mixture of sync/async preprocessors 1`] = ` +const TRANSFORMED = { + filename: '/fruits/banana.js', + script: 'module.exports = "banana";', + config: '{"collectCoverage":false,"collectCoverageFrom":[],"coverageProvider":"babel","supportsDynamicImport":false,"supportsExportNamespaceFrom":false,"supportsStaticESM":false,"supportsTopLevelAwait":false,"instrument":false,"cacheFS":{},"config":{"automock":false,"cache":true,"cacheDirectory":"/cache/","clearMocks":false,"coveragePathIgnorePatterns":[],"cwd":"/test_root_dir/","detectLeaks":false,"detectOpenHandles":false,"errorOnDeprecated":false,"extensionsToTreatAsEsm":[],"extraGlobals":[],"forceCoverageMatch":[],"globals":{},"haste":{},"injectGlobals":true,"moduleDirectories":[],"moduleFileExtensions":["js"],"moduleLoader":"/test_module_loader_path","moduleNameMapper":[],"modulePathIgnorePatterns":[],"modulePaths":[],"name":"test","prettierPath":"prettier","resetMocks":false,"resetModules":false,"restoreMocks":false,"rootDir":"/","roots":[],"runner":"jest-runner","setupFiles":[],"setupFilesAfterEnv":[],"skipFilter":false,"skipNodeResolution":false,"slowTestThreshold":5,"snapshotSerializers":[],"testEnvironment":"node","testEnvironmentOptions":{},"testLocationInResults":false,"testMatch":[],"testPathIgnorePatterns":[],"testRegex":["\\\\.test\\\\.js$"],"testRunner":"jest-circus/runner","testURL":"http://localhost","timers":"real","transform":[["\\\\.js$","test_async_preprocessor",{}],["\\\\.css$","css-preprocessor",{}]],"transformIgnorePatterns":["/node_modules/"],"watchPathIgnorePatterns":[]},"configString":"{\\"automock\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extensionsToTreatAsEsm\\":[],\\"extraGlobals\\":[],\\"forceCoverageMatch\\":[],\\"globals\\":{},\\"haste\\":{},\\"injectGlobals\\":true,\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"slowTestThreshold\\":5,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-circus/runner\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"\\\\\\\\.js$\\",\\"test_async_preprocessor\\",{}],[\\"\\\\\\\\.css$\\",\\"css-preprocessor\\",{}]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"watchPathIgnorePatterns\\":[]}","transformerConfig":{}}', +}; +`; + +exports[`ScriptTransformer uses mixture of sync/async preprocessors 2`] = ` +module.exports = { + filename: /styles/App.css, + rawFirstLine: root {, +}; +`; + +exports[`ScriptTransformer uses mixture of sync/async preprocessors 3`] = `module.exports = "react";`; + exports[`ScriptTransformer uses multiple preprocessors 1`] = ` const TRANSFORMED = { filename: '/fruits/banana.js', @@ -284,4 +685,6 @@ const TRANSFORMED = { exports[`ScriptTransformer uses the supplied preprocessor 2`] = `module.exports = "react";`; +exports[`ScriptTransformer warns of unparseable inlined source maps from the async preprocessor 1`] = `jest-transform: The source map produced for the file /fruits/banana.js by async-preprocessor-with-sourcemaps was invalid. Proceeding without source mapping for that file.`; + exports[`ScriptTransformer warns of unparseable inlined source maps from the preprocessor 1`] = `jest-transform: The source map produced for the file /fruits/banana.js by preprocessor-with-sourcemaps was invalid. Proceeding without source mapping for that file.`; diff --git a/packages/jest-transform/src/index.ts b/packages/jest-transform/src/index.ts index 471612ffb6c4..90ac16070cee 100644 --- a/packages/jest-transform/src/index.ts +++ b/packages/jest-transform/src/index.ts @@ -14,6 +14,8 @@ export {default as shouldInstrument} from './shouldInstrument'; export type { CallerTransformOptions, Transformer, + SyncTransformer, + AsyncTransformer, ShouldInstrumentOptions, Options as TransformationOptions, TransformOptions, diff --git a/packages/jest-transform/src/types.ts b/packages/jest-transform/src/types.ts index 3738f4840a0e..972dfda3b440 100644 --- a/packages/jest-transform/src/types.ts +++ b/packages/jest-transform/src/types.ts @@ -63,9 +63,9 @@ export interface TransformOptions transformerConfig: OptionType; } -export interface Transformer { +export interface SyncTransformer { canInstrument?: boolean; - createTransformer?: (options?: OptionType) => Transformer; + createTransformer?: (options?: OptionType) => SyncTransformer; getCacheKey?: ( sourceText: string, @@ -73,9 +73,52 @@ export interface Transformer { options: TransformOptions, ) => string; + getCacheKeyAsync?: ( + sourceText: string, + sourcePath: Config.Path, + options: TransformOptions, + ) => Promise; + process: ( sourceText: string, sourcePath: Config.Path, options: TransformOptions, ) => TransformedSource; + + processAsync?: ( + sourceText: string, + sourcePath: Config.Path, + options?: TransformOptions, + ) => Promise; } + +export interface AsyncTransformer { + canInstrument?: boolean; + createTransformer?: (options?: OptionType) => AsyncTransformer; + + getCacheKey?: ( + sourceText: string, + sourcePath: Config.Path, + options: TransformOptions, + ) => string; + + getCacheKeyAsync?: ( + sourceText: string, + sourcePath: Config.Path, + options: TransformOptions, + ) => Promise; + + process?: ( + sourceText: string, + sourcePath: Config.Path, + options?: TransformOptions, + ) => TransformedSource; + + processAsync: ( + sourceText: string, + sourcePath: Config.Path, + options?: TransformOptions, + ) => Promise; +} + +export type Transformer = SyncTransformer | AsyncTransformer;