diff --git a/CHANGELOG.md b/CHANGELOG.md index 402c9a3d996a..6ae9b730a11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - `[jest-snapshot]` [**BREAKING**] Add support for [Error causes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) in snapshots ([#13965](https://github.com/facebook/jest/pull/13965)) - `[jest-snapshot]` Support Prettier 3 ([#14566](https://github.com/facebook/jest/pull/14566)) - `[pretty-format]` [**BREAKING**] Do not render empty string children (`''`) in React plugin ([#14470](https://github.com/facebook/jest/pull/14470)) +- `[jest-circus, jest-jasmine2, jest-runtime, jest-snapshot]` Add support for `snapshotResolver` and `snapshotSerializers` written in ESM ([#12014](https://github.com/facebook/jest/pull/12014)) ### Fixes diff --git a/e2e/__tests__/transform.test.ts b/e2e/__tests__/transform.test.ts index 458a014f232e..024f1b28526c 100644 --- a/e2e/__tests__/transform.test.ts +++ b/e2e/__tests__/transform.test.ts @@ -344,4 +344,42 @@ describe('transform-esm-testrunner', () => { expect(json.success).toBe(true); expect(json.numPassedTests).toBe(1); }); + + describe('transform-esm-snapshotResolver', () => { + const dir = path.resolve( + __dirname, + '..', + 'transform/transform-esm-snapshotResolver', + ); + const snapshotDir = path.resolve(dir, '__snapshots__'); + const snapshotFile = path.resolve(snapshotDir, 'snapshot.test.js.snap'); + + const cleanupTest = () => { + if (fs.existsSync(snapshotFile)) { + fs.unlinkSync(snapshotFile); + } + if (fs.existsSync(snapshotDir)) { + fs.rmdirSync(snapshotDir); + } + }; + + beforeAll(() => { + runYarnInstall(dir); + }); + beforeEach(cleanupTest); + afterAll(cleanupTest); + + it('should transform the snapshotResolver', () => { + const result = runJest(dir, ['-w=1', '--no-cache', '--ci=false'], { + nodeOptions: '--experimental-vm-modules --no-warnings', + }); + + expect(result.stderr).toMatch('1 snapshot written from 1 test suite'); + + const contents = require(snapshotFile); + expect(contents).toHaveProperty( + 'snapshots are written to custom location 1', + ); + }); + }); }); diff --git a/e2e/snapshot-resolver-esm/__tests__/snapshot.test.js b/e2e/snapshot-resolver-esm/__tests__/snapshot.test.js new file mode 100644 index 000000000000..d1bc86b0996d --- /dev/null +++ b/e2e/snapshot-resolver-esm/__tests__/snapshot.test.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +test('snapshots are written to custom location', () => { + expect('foobar').toMatchSnapshot(); +}); diff --git a/e2e/snapshot-resolver-esm/customSnapshotResolver.mjs b/e2e/snapshot-resolver-esm/customSnapshotResolver.mjs new file mode 100644 index 000000000000..220ed2672784 --- /dev/null +++ b/e2e/snapshot-resolver-esm/customSnapshotResolver.mjs @@ -0,0 +1,18 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default { + resolveSnapshotPath: (testPath, snapshotExtension) => + testPath.replace('__tests__', '__snapshots__') + snapshotExtension, + + resolveTestPath: (snapshotFilePath, snapshotExtension) => + snapshotFilePath + .replace('__snapshots__', '__tests__') + .slice(0, -snapshotExtension.length), + + testPathForConsistencyCheck: 'foo/__tests__/bar.test.js', +}; diff --git a/e2e/snapshot-resolver-esm/package.json b/e2e/snapshot-resolver-esm/package.json new file mode 100644 index 000000000000..418f68b08ca8 --- /dev/null +++ b/e2e/snapshot-resolver-esm/package.json @@ -0,0 +1,6 @@ +{ + "jest": { + "testEnvironment": "node", + "snapshotResolver": "/customSnapshotResolver.mjs" + } +} diff --git a/e2e/snapshot-serializers-esm/__tests__/snapshot.test.js b/e2e/snapshot-serializers-esm/__tests__/snapshot.test.js new file mode 100644 index 000000000000..093024192c62 --- /dev/null +++ b/e2e/snapshot-serializers-esm/__tests__/snapshot.test.js @@ -0,0 +1,110 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +'use strict'; + +describe('snapshot serializers', () => { + it('works with first plugin', () => { + const test = { + foo: 1, + }; + expect(test).toMatchSnapshot(); + }); + + it('works with second plugin', () => { + const test = { + bar: 2, + }; + expect(test).toMatchSnapshot(); + }); + + it('works with nested serializable objects', () => { + const test = { + foo: { + bar: 2, + }, + }; + expect(test).toMatchSnapshot(); + }); + + it('works with default serializers', () => { + const test = { + $$typeof: Symbol.for('react.test.json'), + children: null, + props: { + id: 'foo', + }, + type: 'div', + }; + expect(test).toMatchSnapshot(); + }); + + it('works with prepended plugins and default serializers', () => { + const test = { + $$typeof: Symbol.for('react.test.json'), + children: null, + props: { + aProp: {a: 6}, + bProp: {foo: 8}, + }, + type: 'div', + }; + expect(test).toMatchSnapshot(); + }); + + it('works with prepended plugins from expect method called once', () => { + const test = { + $$typeof: Symbol.for('react.test.json'), + children: null, + props: { + aProp: {a: 6}, + bProp: {foo: 8}, + }, + type: 'div', + }; + // Add plugin that overrides foo specified by Jest config in package.json + expect.addSnapshotSerializer({ + print: (val, serialize) => `Foo: ${serialize(val.foo)}`, + test: val => val && val.hasOwnProperty('foo'), + }); + expect(test).toMatchSnapshot(); + }); + + it('works with prepended plugins from expect method called twice', () => { + const test = { + $$typeof: Symbol.for('react.test.json'), + children: null, + props: { + aProp: {a: 6}, + bProp: {foo: 8}, + }, + type: 'div', + }; + // Add plugin that overrides preceding added plugin + expect.addSnapshotSerializer({ + print: (val, serialize) => `FOO: ${serialize(val.foo)}`, + test: val => val && val.hasOwnProperty('foo'), + }); + expect(test).toMatchSnapshot(); + }); + + it('works with array of strings in property matcher', () => { + expect({ + arrayOfStrings: ['stream'], + }).toMatchSnapshot({ + arrayOfStrings: ['stream'], + }); + }); + + it('works with expect.XXX within array in property matcher', () => { + expect({ + arrayOfStrings: ['stream'], + }).toMatchSnapshot({ + arrayOfStrings: [expect.any(String)], + }); + }); +}); diff --git a/e2e/snapshot-serializers-esm/package.json b/e2e/snapshot-serializers-esm/package.json new file mode 100644 index 000000000000..2eab6d3f2150 --- /dev/null +++ b/e2e/snapshot-serializers-esm/package.json @@ -0,0 +1,12 @@ +{ + "jest": { + "testEnvironment": "node", + "transform": { + "\\.js$": "/transformer.js" + }, + "snapshotSerializers": [ + "./plugins/foo", + "/plugins/bar" + ] + } +} diff --git a/e2e/snapshot-serializers-esm/plugins/bar.mjs b/e2e/snapshot-serializers-esm/plugins/bar.mjs new file mode 100644 index 000000000000..e9c5ec48a9b2 --- /dev/null +++ b/e2e/snapshot-serializers-esm/plugins/bar.mjs @@ -0,0 +1,11 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {createPlugin} from '../utils'; + +// We inject the call to "createPlugin('bar') through the transformer" diff --git a/e2e/snapshot-serializers-esm/plugins/foo/index.mjs b/e2e/snapshot-serializers-esm/plugins/foo/index.mjs new file mode 100644 index 000000000000..29993c95db73 --- /dev/null +++ b/e2e/snapshot-serializers-esm/plugins/foo/index.mjs @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {createPlugin} from '../../utils'; +export default createPlugin('foo'); diff --git a/e2e/snapshot-serializers-esm/transformer.js b/e2e/snapshot-serializers-esm/transformer.js new file mode 100644 index 000000000000..ad239d660c68 --- /dev/null +++ b/e2e/snapshot-serializers-esm/transformer.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +module.exports = { + process(src, filename) { + if (/bar.mjs$/.test(filename)) { + return `${src};\nexport default createPlugin('bar');`; + } + return src; + }, +}; diff --git a/e2e/snapshot-serializers-esm/utils.mjs b/e2e/snapshot-serializers-esm/utils.mjs new file mode 100644 index 000000000000..95e8a512fe80 --- /dev/null +++ b/e2e/snapshot-serializers-esm/utils.mjs @@ -0,0 +1,12 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export const createPlugin = prop => ({ + print: (val, serialize) => `${prop} - ${serialize(val[prop])}`, + test: val => val && val.hasOwnProperty(prop), +}); diff --git a/e2e/transform/transform-esm-snapshotResolver/__tests__/snapshot.test.js b/e2e/transform/transform-esm-snapshotResolver/__tests__/snapshot.test.js new file mode 100644 index 000000000000..d1bc86b0996d --- /dev/null +++ b/e2e/transform/transform-esm-snapshotResolver/__tests__/snapshot.test.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +test('snapshots are written to custom location', () => { + expect('foobar').toMatchSnapshot(); +}); diff --git a/e2e/transform/transform-esm-snapshotResolver/customSnapshotResolver.mjs b/e2e/transform/transform-esm-snapshotResolver/customSnapshotResolver.mjs new file mode 100644 index 000000000000..7ba707ecbe98 --- /dev/null +++ b/e2e/transform/transform-esm-snapshotResolver/customSnapshotResolver.mjs @@ -0,0 +1,18 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default { + resolveSnapshotPath: (testPath, snapshotExtension) => + testPath.replace('__tests__', '__snapshots__') + snapshotExtension, + + resolveTestPath: (snapshotFilePath, snapshotExtension) => + snapshotFilePath + .replace('__snapshots__', '__tests__') + .slice(0, -(snapshotExtension || '').length), + + testPathForConsistencyCheck: 'foo/__tests__/bar.test.js', +}; diff --git a/e2e/transform/transform-esm-snapshotResolver/package.json b/e2e/transform/transform-esm-snapshotResolver/package.json new file mode 100644 index 000000000000..418f68b08ca8 --- /dev/null +++ b/e2e/transform/transform-esm-snapshotResolver/package.json @@ -0,0 +1,6 @@ +{ + "jest": { + "testEnvironment": "node", + "snapshotResolver": "/customSnapshotResolver.mjs" + } +} diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts index 2a16a8635a66..800b8aeb4cae 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts @@ -10,7 +10,7 @@ import type {TestFileEvent, TestResult} from '@jest/test-result'; import type {Config} from '@jest/types'; import type Runtime from 'jest-runtime'; import type {SnapshotState} from 'jest-snapshot'; -import {deepCyclicCopy} from 'jest-util'; +import {deepCyclicCopy, interopRequireDefault} from 'jest-util'; const FRAMEWORK_INITIALIZER = require.resolve('./jestAdapterInit'); @@ -27,11 +27,28 @@ const jestAdapter = async ( FRAMEWORK_INITIALIZER, ); + const localRequire = async ( + path: string, + applyInteropRequireDefault: boolean = false, + ): Promise => { + const esm = runtime.unstable_shouldLoadAsEsm(path); + + if (esm) { + return runtime.unstable_importModule(path) as any; + } else { + const requiredModule = runtime.requireModule(path); + if (!applyInteropRequireDefault) { + return requiredModule; + } + return interopRequireDefault(requiredModule).default; + } + }; + const {globals, snapshotState} = await initialize({ config, environment, globalConfig, - localRequire: runtime.requireModule.bind(runtime), + localRequire, parentProcess: process, runtime, sendMessageToJest, @@ -76,22 +93,10 @@ const jestAdapter = async ( const setupAfterEnvStart = Date.now(); for (const path of config.setupFilesAfterEnv) { - const esm = runtime.unstable_shouldLoadAsEsm(path); - - if (esm) { - await runtime.unstable_importModule(path); - } else { - runtime.requireModule(path); - } + await localRequire(path); } const setupAfterEnvEnd = Date.now(); - const esm = runtime.unstable_shouldLoadAsEsm(testPath); - - if (esm) { - await runtime.unstable_importModule(testPath); - } else { - runtime.requireModule(testPath); - } + await localRequire(testPath); const setupAfterEnvPerfStats = { setupAfterEnvEnd, diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts index dcb25bb15a61..919e351973f0 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts @@ -54,7 +54,10 @@ export const initialize = async ({ environment: JestEnvironment; runtime: Runtime; globalConfig: Config.GlobalConfig; - localRequire: (path: string) => T; + localRequire: ( + path: string, + applyInteropRequireDefault?: boolean, + ) => Promise; testPath: string; parentProcess: typeof Process; sendMessageToJest?: TestFileEvent; @@ -113,7 +116,7 @@ export const initialize = async ({ // Jest tests snapshotSerializers in order preceding built-in serializers. // Therefore, add in reverse because the last added is the first tested. for (const path of config.snapshotSerializers.concat().reverse()) - addSerializer(localRequire(path)); + addSerializer(await localRequire(path)); const snapshotResolver = await buildSnapshotResolver(config, localRequire); const snapshotPath = snapshotResolver.resolveSnapshotPath(testPath); diff --git a/packages/jest-jasmine2/src/index.ts b/packages/jest-jasmine2/src/index.ts index 09f575240981..162d81b5e10f 100644 --- a/packages/jest-jasmine2/src/index.ts +++ b/packages/jest-jasmine2/src/index.ts @@ -12,7 +12,7 @@ import type {TestResult} from '@jest/test-result'; import type {Config, Global} from '@jest/types'; import type Runtime from 'jest-runtime'; import type {SnapshotState} from 'jest-snapshot'; -import {ErrorWithStack} from 'jest-util'; +import {interopRequireDefault, ErrorWithStack} from 'jest-util'; import installEach from './each'; import {installErrorOnPrivate} from './errorOnPrivate'; import type Spec from './jasmine/Spec'; @@ -163,6 +163,23 @@ export default async function jasmine2( }); } + const localRequire = async ( + path: string, + applyInteropRequireDefault: boolean = false, + ): Promise => { + const esm = runtime.unstable_shouldLoadAsEsm(path); + + if (esm) { + return runtime.unstable_importModule(path) as any; + } else { + const requiredModule = runtime.requireModule(path); + if (!applyInteropRequireDefault) { + return requiredModule; + } + return interopRequireDefault(requiredModule).default; + } + }; + const snapshotState: SnapshotState = await runtime .requireInternalModule( require.resolve('./setup_jest_globals.js'), @@ -170,7 +187,7 @@ export default async function jasmine2( .default({ config, globalConfig, - localRequire: runtime.requireModule.bind(runtime), + localRequire, testPath, }); diff --git a/packages/jest-jasmine2/src/setup_jest_globals.ts b/packages/jest-jasmine2/src/setup_jest_globals.ts index 33e6894ad957..2c67fc4ef462 100644 --- a/packages/jest-jasmine2/src/setup_jest_globals.ts +++ b/packages/jest-jasmine2/src/setup_jest_globals.ts @@ -12,7 +12,6 @@ import { addSerializer, buildSnapshotResolver, } from 'jest-snapshot'; -import type {Plugin} from 'pretty-format'; import type { Attributes, default as JasmineSpec, @@ -22,7 +21,10 @@ import type { export type SetupOptions = { config: Config.ProjectConfig; globalConfig: Config.GlobalConfig; - localRequire: (moduleName: string) => Plugin; + localRequire: ( + path: string, + applyInteropRequireDefault?: boolean, + ) => Promise; testPath: string; }; @@ -96,7 +98,7 @@ export default async function setupJestGlobals({ // Jest tests snapshotSerializers in order preceding built-in serializers. // Therefore, add in reverse because the last added is the first tested. for (let i = config.snapshotSerializers.length - 1; i >= 0; i--) { - addSerializer(localRequire(config.snapshotSerializers[i])); + addSerializer(await localRequire(config.snapshotSerializers[i])); } patchJasmine(); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 9156101ccae1..d2b69e4a7c32 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -769,7 +769,11 @@ export default class Runtime { const module = await this.loadEsmModule(modulePath, query); - return this.linkAndEvaluateModule(module); + const evaluatedModule = await this.linkAndEvaluateModule(module); + + return evaluatedModule instanceof SourceTextModule + ? evaluatedModule.namespace.default + : evaluatedModule; } private loadCjsAsEsm(from: string, modulePath: string, context: VMContext) { diff --git a/packages/jest-snapshot/src/SnapshotResolver.ts b/packages/jest-snapshot/src/SnapshotResolver.ts index bd34e2037a0a..ec1ec2a57e31 100644 --- a/packages/jest-snapshot/src/SnapshotResolver.ts +++ b/packages/jest-snapshot/src/SnapshotResolver.ts @@ -9,7 +9,6 @@ import * as path from 'path'; import chalk = require('chalk'); import {createTranspilingRequire} from '@jest/transform'; import type {Config} from '@jest/types'; -import {interopRequireDefault} from 'jest-util'; export type SnapshotResolver = { /** Resolves from `testPath` to snapshot path. */ @@ -28,13 +27,16 @@ export const isSnapshotPath = (path: string): boolean => const cache = new Map(); -type LocalRequire = (module: string) => unknown; +type LocalRequire = ( + path: string, + applyInteropRequireDefault?: boolean, +) => Promise; export const buildSnapshotResolver = async ( config: Config.ProjectConfig, - localRequire: Promise | LocalRequire = createTranspilingRequire( - config, - ), + localRequire: + | Promise> + | LocalRequire = createTranspilingRequire(config), ): Promise => { const key = config.rootDir; @@ -48,7 +50,7 @@ export const buildSnapshotResolver = async ( }; async function createSnapshotResolver( - localRequire: LocalRequire, + localRequire: LocalRequire, snapshotResolverPath?: string | null, ): Promise { return typeof snapshotResolverPath === 'string' @@ -81,11 +83,12 @@ function createDefaultSnapshotResolver(): SnapshotResolver { async function createCustomSnapshotResolver( snapshotResolverPath: string, - localRequire: LocalRequire, + localRequire: LocalRequire, ): Promise { - const custom: SnapshotResolver = interopRequireDefault( - await localRequire(snapshotResolverPath), - ).default; + const custom: SnapshotResolver = await localRequire( + snapshotResolverPath, + true, + ); const keys: Array<[keyof SnapshotResolver, string]> = [ ['resolveSnapshotPath', 'function'], diff --git a/packages/jest-snapshot/src/__tests__/SnapshotResolver.test.ts b/packages/jest-snapshot/src/__tests__/SnapshotResolver.test.ts index ba8326d3b954..9fdad8037155 100644 --- a/packages/jest-snapshot/src/__tests__/SnapshotResolver.test.ts +++ b/packages/jest-snapshot/src/__tests__/SnapshotResolver.test.ts @@ -81,6 +81,45 @@ describe('custom resolver in project config', () => { }); }); +describe('custom resolver written in ESM in project config', () => { + let snapshotResolver: SnapshotResolver; + const customSnapshotResolverFile = path.join( + __dirname, + 'fixtures', + 'customSnapshotResolver.mjs', + ); + const projectConfig = makeProjectConfig({ + rootDir: 'custom1', + snapshotResolver: customSnapshotResolverFile, + }); + + beforeEach(async () => { + snapshotResolver = await buildSnapshotResolver(projectConfig); + }); + + it('returns cached object if called multiple times', async () => { + await expect(buildSnapshotResolver(projectConfig)).resolves.toBe( + snapshotResolver, + ); + }); + + it('resolveSnapshotPath()', () => { + expect( + snapshotResolver.resolveSnapshotPath( + path.resolve('/abc/cde/__tests__/a.test.js'), + ), + ).toBe(path.resolve('/abc/cde/__snapshots__/a.test.js.snap')); + }); + + it('resolveTestPath()', () => { + expect( + snapshotResolver.resolveTestPath( + path.resolve('/abc', 'cde', '__snapshots__', 'a.test.js.snap'), + ), + ).toBe(path.resolve('/abc/cde/__tests__/a.test.js')); + }); +}); + describe('malformed custom resolver in project config', () => { const newProjectConfig = (filename: string) => { const customSnapshotResolverFile = path.join( diff --git a/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver.mjs b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver.mjs new file mode 100644 index 000000000000..220ed2672784 --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver.mjs @@ -0,0 +1,18 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default { + resolveSnapshotPath: (testPath, snapshotExtension) => + testPath.replace('__tests__', '__snapshots__') + snapshotExtension, + + resolveTestPath: (snapshotFilePath, snapshotExtension) => + snapshotFilePath + .replace('__snapshots__', '__tests__') + .slice(0, -snapshotExtension.length), + + testPathForConsistencyCheck: 'foo/__tests__/bar.test.js', +};