diff --git a/CHANGELOG.md b/CHANGELOG.md index bee7bad05887..9421514c3e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,9 +75,11 @@ - `[*]` [**BREAKING**] Drop support for `typescript@3.8`, minimum version is now `4.2` ([#11142](https://github.com/facebook/jest/pull/11142)) - `[*]` Bundle all `.d.ts` files into a single `index.d.ts` per module ([#12345](https://github.com/facebook/jest/pull/12345)) - `[*]` Use `globalThis` instead of `global` ([#12447](https://github.com/facebook/jest/pull/12447)) +- `[babel-jest]` [**BREAKING**] Only export `createTransformer` ([#12407](https://github.com/facebook/jest/pull/12407)) - `[docs]` Add note about not mixing `done()` with Promises ([#11077](https://github.com/facebook/jest/pull/11077)) - `[docs, examples]` Update React examples to match with the new React guidelines for code examples ([#12217](https://github.com/facebook/jest/pull/12217)) - `[docs]` Add clarity for module factory hoisting limitations ([#12453](https://github.com/facebook/jest/pull/12453)) +- `[docs]` Add more information about how code transformers work ([#12407](https://github.com/facebook/jest/pull/12407)) - `[expect]` [**BREAKING**] Remove support for importing `build/utils` ([#12323](https://github.com/facebook/jest/pull/12323)) - `[expect]` [**BREAKING**] Migrate to ESM ([#12344](https://github.com/facebook/jest/pull/12344)) - `[expect]` [**BREAKING**] Snapshot matcher types are moved to `@jest/expect` ([#12404](https://github.com/facebook/jest/pull/12404)) diff --git a/docs/CodeTransformation.md b/docs/CodeTransformation.md index 3b1e0f8e3100..05ef21d770bb 100644 --- a/docs/CodeTransformation.md +++ b/docs/CodeTransformation.md @@ -22,72 +22,77 @@ If you override the `transform` configuration option `babel-jest` will no longer You can write your own transformer. The API of a transformer is as follows: ```ts +// This version of the interface you are seeing on the website has been trimmed down for brevity +// For the full definition, see `packages/jest-transform/src/types.ts` in https://github.com/facebook/jest +// (taking care in choosing the right tag/commit for your version of Jest) + +interface TransformOptions { + supportsDynamicImport: boolean; + supportsExportNamespaceFrom: boolean; + supportsStaticESM: boolean; + supportsTopLevelAwait: boolean; + instrument: boolean; + /** a cached file system which is used in jest-runtime - useful to improve performance */ + cacheFS: Map; + config: Config.ProjectConfig; + /** A stringified version of the configuration - useful in cache busting */ + configString: string; + /** the options passed through Jest's config by the user */ + transformerConfig: OptionType; +} + interface SyncTransformer { - /** - * Indicates if the transformer is capable of instrumenting the code for code coverage. - * - * If V8 coverage is _not_ active, and this is `true`, Jest will assume the code is instrumented. - * If V8 coverage is _not_ active, and this is `false`. Jest will instrument the code returned by this transformer using Babel. - */ canInstrument?: boolean; - createTransformer?: (options?: OptionType) => SyncTransformer; getCacheKey?: ( sourceText: string, - sourcePath: Config.Path, + sourcePath: string, options: TransformOptions, ) => string; getCacheKeyAsync?: ( sourceText: string, - sourcePath: Config.Path, + sourcePath: string, options: TransformOptions, ) => Promise; process: ( sourceText: string, - sourcePath: Config.Path, + sourcePath: string, options: TransformOptions, ) => TransformedSource; processAsync?: ( sourceText: string, - sourcePath: Config.Path, + sourcePath: string, options: TransformOptions, ) => Promise; } interface AsyncTransformer { - /** - * Indicates if the transformer is capable of instrumenting the code for code coverage. - * - * If V8 coverage is _not_ active, and this is `true`, Jest will assume the code is instrumented. - * If V8 coverage is _not_ active, and this is `false`. Jest will instrument the code returned by this transformer using Babel. - */ canInstrument?: boolean; - createTransformer?: (options?: OptionType) => AsyncTransformer; getCacheKey?: ( sourceText: string, - sourcePath: Config.Path, + sourcePath: string, options: TransformOptions, ) => string; getCacheKeyAsync?: ( sourceText: string, - sourcePath: Config.Path, + sourcePath: string, options: TransformOptions, ) => Promise; process?: ( sourceText: string, - sourcePath: Config.Path, + sourcePath: string, options: TransformOptions, ) => TransformedSource; processAsync: ( sourceText: string, - sourcePath: Config.Path, + sourcePath: string, options: TransformOptions, ) => Promise; } @@ -96,35 +101,25 @@ type Transformer = | SyncTransformer | AsyncTransformer; -interface TransformOptions { - /** - * If a transformer does module resolution and reads files, it should populate `cacheFS` so that - * Jest avoids reading the same files again, improving performance. `cacheFS` stores entries of - * - */ - cacheFS: Map; - config: Config.ProjectConfig; - /** A stringified version of the configuration - useful in cache busting */ - configString: string; - instrument: boolean; - // names are copied from babel: https://babeljs.io/docs/en/options#caller - supportsDynamicImport: boolean; - supportsExportNamespaceFrom: boolean; - supportsStaticESM: boolean; - supportsTopLevelAwait: boolean; - /** the options passed through Jest's config by the user */ - transformerConfig: OptionType; -} - -type TransformedSource = - | {code: string; map?: RawSourceMap | string | null} - | string; +type TransformerCreator< + X extends Transformer, + OptionType = unknown, +> = (options?: OptionType) => X; -// Config.ProjectConfig can be seen in code [here](https://github.com/facebook/jest/blob/v26.6.3/packages/jest-types/src/Config.ts#L323) -// RawSourceMap comes from [`source-map`](https://github.com/mozilla/source-map/blob/0.6.1/source-map.d.ts#L6-L12) +type TransformerFactory = { + createTransformer: TransformerCreator; +}; ``` -As can be seen, only `process` or `processAsync` is mandatory to implement, although we highly recommend implementing `getCacheKey` as well, so we don't waste resources transpiling the same source file when we can read its previous result from disk. You can use [`@jest/create-cache-key-function`](https://www.npmjs.com/package/@jest/create-cache-key-function) to help implement it. +There are a couple of ways you can import code into Jest - using Common JS (`require`) or ECMAScript Modules (`import` - which exists in static and dynamic versions). Jest passes files through code transformation on demand (for instance when a `require` or `import` is evaluated). This process, also known as "transpilation", might happen _synchronously_ (in the case of `require`), or _asynchronously_ (in the case of `import` or `import()`, the latter of which also works from Common JS modules). For this reason, the interface exposes both pairs of methods for asynchronous and synchronous processes: `process{Async}` and `getCacheKey{Async}`. The latter is called to figure out if we need to call `process{Async}` at all. Since async transformation can happen synchronously without issue, it's possible for the async case to "fall back" to the sync variant, but not vice versa. + +So if your code base is ESM only implementing the async variants is sufficient. Otherwise, if any code is loaded through `require` (including `createRequire` from within ESM), then you need to implement the synchronous variant. Be aware that `node_modules` is not transpiled with default config. + +Semi-related to this are the supports flags we pass (see `CallerTransformOptions` above), but those should be used within the transform to figure out if it should return ESM or CJS, and has no direct bearing on sync vs async + +Though not required, we _highly recommend_ implementing `getCacheKey` as well, so we do not waste resources transpiling when we could have read its previous result from disk. You can use [`@jest/create-cache-key-function`](https://www.npmjs.com/package/@jest/create-cache-key-function) to help implement it. + +Instead of having your custom transformer implement the `Transformer` interface directly, you can choose to export `createTransformer`, a factory function to dynamically create transformers. This is to allow having a transformer config in your jest config. Note that [ECMAScript module](ECMAScriptModules.md) support is indicated by the passed in `supports*` options. Specifically `supportsDynamicImport: true` means the transformer can return `import()` expressions, which is supported by both ESM and CJS. If `supportsStaticESM: true` it means top level `import` statements are supported and the code will be interpreted as ESM and not CJS. See [Node's docs](https://nodejs.org/api/esm.html#esm_differences_between_es_modules_and_commonjs) for details on the differences. diff --git a/e2e/transform/babel-jest-async/transformer.js b/e2e/transform/babel-jest-async/transformer.js index 9bc35a2a9ad1..41a96de04751 100644 --- a/e2e/transform/babel-jest-async/transformer.js +++ b/e2e/transform/babel-jest-async/transformer.js @@ -6,10 +6,10 @@ */ import {fileURLToPath} from 'url'; -import babelJest from 'babel-jest'; +import {createTransformer} from 'babel-jest'; export default { - ...babelJest.default.createTransformer({ + ...createTransformer({ presets: ['@babel/preset-flow'], root: fileURLToPath(import.meta.url), }), diff --git a/packages/babel-jest/src/__tests__/getCacheKey.test.ts b/packages/babel-jest/src/__tests__/getCacheKey.test.ts index 63e38cff3cb3..d6eaa5d9295b 100644 --- a/packages/babel-jest/src/__tests__/getCacheKey.test.ts +++ b/packages/babel-jest/src/__tests__/getCacheKey.test.ts @@ -9,6 +9,8 @@ import type {TransformOptions as BabelTransformOptions} from '@babel/core'; import type {TransformOptions as JestTransformOptions} from '@jest/transform'; import babelJest from '../index'; +const {getCacheKey} = babelJest.createTransformer(); + const processVersion = process.version; const nodeEnv = process.env.NODE_ENV; const babelEnv = process.env.BABEL_ENV; @@ -39,11 +41,7 @@ describe('getCacheKey', () => { instrument: true, } as JestTransformOptions; - const oldCacheKey = babelJest.getCacheKey( - sourceText, - sourcePath, - transformOptions, - ); + const oldCacheKey = getCacheKey(sourceText, sourcePath, transformOptions); test('returns cache key hash', () => { expect(oldCacheKey.length).toEqual(32); @@ -54,9 +52,9 @@ describe('getCacheKey', () => { readFileSync: () => 'new this file', })); - const {default: babelJest}: typeof import('../index') = require('../index'); + const {createTransformer}: typeof import('../index') = require('../index'); - const newCacheKey = babelJest.getCacheKey( + const newCacheKey = createTransformer().getCacheKey( sourceText, sourcePath, transformOptions, @@ -77,9 +75,9 @@ describe('getCacheKey', () => { }; }); - const {default: babelJest}: typeof import('../index') = require('../index'); + const {createTransformer}: typeof import('../index') = require('../index'); - const newCacheKey = babelJest.getCacheKey( + const newCacheKey = createTransformer().getCacheKey( sourceText, sourcePath, transformOptions, @@ -89,7 +87,7 @@ describe('getCacheKey', () => { }); test('if `sourceText` value is changing', () => { - const newCacheKey = babelJest.getCacheKey( + const newCacheKey = getCacheKey( 'new source text', sourcePath, transformOptions, @@ -99,7 +97,7 @@ describe('getCacheKey', () => { }); test('if `sourcePath` value is changing', () => { - const newCacheKey = babelJest.getCacheKey( + const newCacheKey = getCacheKey( sourceText, 'new-source-path.js', transformOptions, @@ -109,7 +107,7 @@ describe('getCacheKey', () => { }); test('if `configString` value is changing', () => { - const newCacheKey = babelJest.getCacheKey(sourceText, sourcePath, { + const newCacheKey = getCacheKey(sourceText, sourcePath, { ...transformOptions, configString: 'new-config-string', }); @@ -129,9 +127,9 @@ describe('getCacheKey', () => { }; }); - const {default: babelJest}: typeof import('../index') = require('../index'); + const {createTransformer}: typeof import('../index') = require('../index'); - const newCacheKey = babelJest.getCacheKey( + const newCacheKey = createTransformer().getCacheKey( sourceText, sourcePath, transformOptions, @@ -152,9 +150,9 @@ describe('getCacheKey', () => { }; }); - const {default: babelJest}: typeof import('../index') = require('../index'); + const {createTransformer}: typeof import('../index') = require('../index'); - const newCacheKey = babelJest.getCacheKey( + const newCacheKey = createTransformer().getCacheKey( sourceText, sourcePath, transformOptions, @@ -164,7 +162,7 @@ describe('getCacheKey', () => { }); test('if `instrument` value is changing', () => { - const newCacheKey = babelJest.getCacheKey(sourceText, sourcePath, { + const newCacheKey = getCacheKey(sourceText, sourcePath, { ...transformOptions, instrument: false, }); @@ -175,11 +173,7 @@ describe('getCacheKey', () => { test('if `process.env.NODE_ENV` value is changing', () => { process.env.NODE_ENV = 'NEW_NODE_ENV'; - const newCacheKey = babelJest.getCacheKey( - sourceText, - sourcePath, - transformOptions, - ); + const newCacheKey = getCacheKey(sourceText, sourcePath, transformOptions); expect(oldCacheKey).not.toEqual(newCacheKey); }); @@ -187,11 +181,7 @@ describe('getCacheKey', () => { test('if `process.env.BABEL_ENV` value is changing', () => { process.env.BABEL_ENV = 'NEW_BABEL_ENV'; - const newCacheKey = babelJest.getCacheKey( - sourceText, - sourcePath, - transformOptions, - ); + const newCacheKey = getCacheKey(sourceText, sourcePath, transformOptions); expect(oldCacheKey).not.toEqual(newCacheKey); }); @@ -200,11 +190,7 @@ describe('getCacheKey', () => { delete process.version; process.version = 'new-node-version'; - const newCacheKey = babelJest.getCacheKey( - sourceText, - sourcePath, - transformOptions, - ); + const newCacheKey = getCacheKey(sourceText, sourcePath, transformOptions); expect(oldCacheKey).not.toEqual(newCacheKey); }); diff --git a/packages/babel-jest/src/__tests__/index.ts b/packages/babel-jest/src/__tests__/index.ts index 7d3db19bdc7a..46dd4677eee3 100644 --- a/packages/babel-jest/src/__tests__/index.ts +++ b/packages/babel-jest/src/__tests__/index.ts @@ -6,7 +6,7 @@ */ import {makeProjectConfig} from '@jest/test-utils'; -import babelJest from '../index'; +import babelJest, {createTransformer} from '../index'; import {loadPartialConfig} from '../loadBabelConfig'; jest.mock('../loadBabelConfig', () => { @@ -20,6 +20,8 @@ jest.mock('../loadBabelConfig', () => { }; }); +const defaultBabelJestTransformer = babelJest.createTransformer(null); + //Mock data for all the tests const sourceString = ` const sum = (a, b) => a+b; @@ -38,11 +40,17 @@ beforeEach(() => { }); test('Returns source string with inline maps when no transformOptions is passed', () => { - const result = babelJest.process(sourceString, 'dummy_path.js', { - config: makeProjectConfig(), - configString: JSON.stringify(makeProjectConfig()), - instrument: false, - }) as any; + const result = defaultBabelJestTransformer.process( + sourceString, + 'dummy_path.js', + { + cacheFS: new Map(), + config: makeProjectConfig(), + configString: JSON.stringify(makeProjectConfig()), + instrument: false, + transformerConfig: {}, + }, + ) as any; expect(typeof result).toBe('object'); expect(result.code).toBeDefined(); expect(result.map).toBeDefined(); @@ -53,13 +61,15 @@ test('Returns source string with inline maps when no transformOptions is passed' }); test('Returns source string with inline maps when no transformOptions is passed async', async () => { - const result: any = await babelJest.processAsync!( + const result: any = await defaultBabelJestTransformer.processAsync!( sourceString, 'dummy_path.js', { + cacheFS: new Map(), config: makeProjectConfig(), configString: JSON.stringify(makeProjectConfig()), instrument: false, + transformerConfig: {}, }, ); expect(typeof result).toBe('object'); @@ -108,10 +118,12 @@ describe('caller option correctly merges from defaults and options', () => { }, ], ])('%j -> %j', (input, output) => { - babelJest.process(sourceString, 'dummy_path.js', { + defaultBabelJestTransformer.process(sourceString, 'dummy_path.js', { + cacheFS: new Map(), config: makeProjectConfig(), configString: JSON.stringify(makeProjectConfig()), instrument: false, + transformerConfig: {}, ...input, }); @@ -130,11 +142,13 @@ describe('caller option correctly merges from defaults and options', () => { }); test('can pass null to createTransformer', () => { - const transformer = babelJest.createTransformer(null); + const transformer = createTransformer(null); transformer.process(sourceString, 'dummy_path.js', { + cacheFS: new Map(), config: makeProjectConfig(), configString: JSON.stringify(makeProjectConfig()), instrument: false, + transformerConfig: {}, }); expect(loadPartialConfig).toHaveBeenCalledTimes(1); diff --git a/packages/babel-jest/src/index.ts b/packages/babel-jest/src/index.ts index 522d5cc58390..ff5fc8b85ce6 100644 --- a/packages/babel-jest/src/index.ts +++ b/packages/babel-jest/src/index.ts @@ -19,6 +19,7 @@ import slash = require('slash'); import type { TransformOptions as JestTransformOptions, SyncTransformer, + TransformerCreator, } from '@jest/transform'; import {loadPartialConfig, loadPartialConfigAsync} from './loadBabelConfig'; @@ -26,8 +27,6 @@ const THIS_FILE = fs.readFileSync(__filename); const jestPresetPath = require.resolve('babel-preset-jest'); const babelIstanbulPlugin = require.resolve('babel-plugin-istanbul'); -type CreateTransformer = SyncTransformer['createTransformer']; - function assertLoadedBabelConfig( babelConfig: Readonly | null, cwd: string, @@ -148,7 +147,10 @@ async function loadBabelOptionsAsync( return addIstanbulInstrumentation(options, jestTransformOptions); } -export const createTransformer: CreateTransformer = userOptions => { +export const createTransformer: TransformerCreator< + SyncTransformer, + TransformOptions +> = userOptions => { const inputOptions = userOptions ?? {}; const options = { @@ -269,11 +271,10 @@ export const createTransformer: CreateTransformer = userOptions => { }; }; -const transformer: SyncTransformer = { - ...createTransformer(), - // Assigned here so only the exported transformer has `createTransformer`, - // instead of all created transformers by the function +const transformerFactory = { + // Assigned here, instead of as a separate export, due to limitations in Jest's + // requireOrImportModule, requiring all exports to be on the `default` export createTransformer, }; -export default transformer; +export default transformerFactory; diff --git a/packages/jest-repl/src/cli/repl.ts b/packages/jest-repl/src/cli/repl.ts index 94cb896c9e68..757af8df679e 100644 --- a/packages/jest-repl/src/cli/repl.ts +++ b/packages/jest-repl/src/cli/repl.ts @@ -78,7 +78,16 @@ if (jestProjectConfig.transform) { } } if (transformerPath) { - transformer = interopRequireDefault(require(transformerPath)).default; + const transformerOrFactory = interopRequireDefault( + require(transformerPath), + ).default; + + if (typeof transformerOrFactory.createTransformer === 'function') { + transformer = transformerOrFactory.createTransformer(transformerConfig); + } else { + transformer = transformerOrFactory; + } + if (typeof transformer.process !== 'function') { throw new TypeError( 'Jest: a transformer must export a `process` function.', diff --git a/packages/jest-transform/src/ScriptTransformer.ts b/packages/jest-transform/src/ScriptTransformer.ts index b1ab7a935f45..4959c200d5cc 100644 --- a/packages/jest-transform/src/ScriptTransformer.ts +++ b/packages/jest-transform/src/ScriptTransformer.ts @@ -42,6 +42,7 @@ import type { TransformResult, TransformedSource, Transformer, + TransformerFactory, } from './types'; // Use `require` to avoid TS rootDir const {version: VERSION} = require('../package.json'); @@ -72,6 +73,13 @@ async function waitForPromiseWithCleanup( } } +// type predicate +function isTransformerFactory( + t: Transformer | TransformerFactory, +): t is TransformerFactory { + return typeof (t as TransformerFactory).createTransformer === 'function'; +} + class ScriptTransformer { private readonly _cache: ProjectCache; private readonly _transformCache = new Map< @@ -259,14 +267,13 @@ class ScriptTransformer { await Promise.all( this._config.transform.map( async ([, transformPath, transformerConfig]) => { - let transformer: Transformer = await requireOrImportModule( - transformPath, - ); + let transformer: Transformer | TransformerFactory = + await requireOrImportModule(transformPath); if (!transformer) { throw new Error(makeInvalidTransformerError(transformPath)); } - if (typeof transformer.createTransformer === 'function') { + if (isTransformerFactory(transformer)) { transformer = transformer.createTransformer(transformerConfig); } if ( diff --git a/packages/jest-transform/src/index.ts b/packages/jest-transform/src/index.ts index 90ac16070cee..e81fe036ba4f 100644 --- a/packages/jest-transform/src/index.ts +++ b/packages/jest-transform/src/index.ts @@ -18,6 +18,7 @@ export type { AsyncTransformer, ShouldInstrumentOptions, Options as TransformationOptions, + TransformerCreator, TransformOptions, TransformResult, TransformedSource, diff --git a/packages/jest-transform/src/types.ts b/packages/jest-transform/src/types.ts index 1d28cbdb30ea..85bb4a31ba54 100644 --- a/packages/jest-transform/src/types.ts +++ b/packages/jest-transform/src/types.ts @@ -76,7 +76,6 @@ export interface SyncTransformer { * If V8 coverage is _not_ active, and this is `false`. Jest will instrument the code returned by this transformer using Babel. */ canInstrument?: boolean; - createTransformer?: (options?: OptionType) => SyncTransformer; getCacheKey?: ( sourceText: string, @@ -111,7 +110,6 @@ export interface AsyncTransformer { * If V8 coverage is _not_ active, and this is `false`. Jest will instrument the code returned by this transformer using Babel. */ canInstrument?: boolean; - createTransformer?: (options?: OptionType) => AsyncTransformer; getCacheKey?: ( sourceText: string, @@ -138,6 +136,28 @@ export interface AsyncTransformer { ) => Promise; } +/** + * We have both sync (`process`) and async (`processAsync`) code transformation, which both can be provided. + * `require` will always use `process`, and `import` will use `processAsync` if it exists, otherwise fall back to `process`. + * Meaning, if you use `import` exclusively you do not need `process`, but in most cases supplying both makes sense: + * Jest transpiles on demand rather than ahead of time, so the sync one needs to exist. + * + * For more info on the sync vs async model, see https://jestjs.io/docs/code-transformation#writing-custom-transformers + */ export type Transformer = | SyncTransformer | AsyncTransformer; + +export type TransformerCreator< + X extends Transformer, + OptionType = unknown, +> = (options?: OptionType) => X; + +/** + * Instead of having your custom transformer implement the Transformer interface + * directly, you can choose to export a factory function to dynamically create + * transformers. This is to allow having a transformer config in your jest config. + */ +export type TransformerFactory = { + createTransformer: TransformerCreator; +};