diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f328baaf38..f7bdbe06619d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Features - `[@jest/transform]` Strip TypeScript types with Node when no transformer claims a `.ts`, `.mts` or `.cts` file ([#16421](https://github.com/jestjs/jest/pull/16421)) +- `[jest-runtime, jest-snapshot]` Support ESM `snapshotResolver` and `snapshotSerializers` configuration modules by loading them outside the test sandbox. Their transitive imports no longer use `jest.mock()` or `moduleNameMapper` ([#16402](https://github.com/jestjs/jest/pull/16402)) ### Fixes diff --git a/docs/Configuration.md b/docs/Configuration.md index 158876c8ace2..0668e92585e6 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1906,7 +1906,7 @@ Default: `undefined` The path to a module that can resolve test\<->snapshot path. This config option lets you customize where Jest stores snapshot files on disk. -```js title="custom-resolver.js" +```js tab={"label":"CommonJS"} title="custom-resolver.js" module.exports = { // resolves from test to snapshot path resolveSnapshotPath: (testPath, snapshotExtension) => @@ -1923,6 +1923,25 @@ module.exports = { }; ``` +```js tab={"label":"ESM"} title="custom-resolver.mjs" +export default { + // resolves from test to snapshot path + resolveSnapshotPath: (testPath, snapshotExtension) => + testPath.replace('__tests__', '__snapshots__') + snapshotExtension, + + // resolves from snapshot to test path + resolveTestPath: (snapshotFilePath, snapshotExtension) => + snapshotFilePath + .replace('__snapshots__', '__tests__') + .slice(0, -snapshotExtension.length), + + // Example test path, used for preflight consistency check of the implementation above + testPathForConsistencyCheck: 'some/__tests__/example.test.js', +}; +``` + +Jest loads the module outside the test sandbox, so it does not use `jest.mock()` or `moduleNameMapper`. Jest applies the configured `transform` when a file matches a transform pattern, except for `.mjs` and `.mts` files, which it loads as native ESM without transformation. An ESM snapshot resolver must use a `default` export. + ### `snapshotSerializers` \[array<string>] Default: `[]` @@ -1931,7 +1950,7 @@ A list of paths to snapshot serializer modules Jest should use for snapshot test Jest has default serializers for built-in JavaScript types, HTML elements (Jest 20.0.0+), ImmutableJS (Jest 20.0.0+) and for React elements. See [snapshot test tutorial](TutorialReactNative.md#snapshot-test) for more information. -```js tab title="custom-serializer.js" +```js tab={"label":"CommonJS"} title="custom-serializer.js" module.exports = { serialize(val, config, indentation, depth, refs, printer) { return `Pretty foo: ${printer(val.foo, config, indentation, depth, refs)}`; @@ -1959,8 +1978,22 @@ const plugin: Plugin = { export default plugin; ``` +```js tab={"label":"ESM"} title="custom-serializer.mjs" +export default { + serialize(val, config, indentation, depth, refs, printer) { + return `Pretty foo: ${printer(val.foo, config, indentation, depth, refs)}`; + }, + + test(val) { + return val && Object.prototype.hasOwnProperty.call(val, 'foo'); + }, +}; +``` + `printer` is a function that serializes a value using existing plugins. +Jest loads serializers outside the test sandbox, so they do not use `jest.mock()` or `moduleNameMapper`. Jest applies the configured `transform` when a file matches a transform pattern, except for `.mjs` and `.mts` files, which it loads as native ESM without transformation. An ESM serializer must use a `default` export. + Add `custom-serializer` to your Jest configuration: ```js tab title="jest.config.js" diff --git a/e2e/__tests__/__snapshots__/snapshotSerializers.test.ts.snap b/e2e/__tests__/__snapshots__/snapshotSerializers.test.ts.snap index 1f5d815cb063..0fe302a3b49b 100644 --- a/e2e/__tests__/__snapshots__/snapshotSerializers.test.ts.snap +++ b/e2e/__tests__/__snapshots__/snapshotSerializers.test.ts.snap @@ -56,3 +56,10 @@ Object { "snapshot serializers works with second plugin 1": "bar - 2", } `; + +exports[`Snapshot serializers written in ESM renders snapshot 1`] = ` +Object { + "loads every configured serializer 1": "second: value", + "uses the first matching serializer 1": "first: value", +} +`; diff --git a/e2e/__tests__/snapshotResolver.test.ts b/e2e/__tests__/snapshotResolver.test.ts index f511495c3596..d5a34766f45e 100644 --- a/e2e/__tests__/snapshotResolver.test.ts +++ b/e2e/__tests__/snapshotResolver.test.ts @@ -9,33 +9,50 @@ import * as path from 'path'; import * as fs from 'graceful-fs'; import runJest from '../runJest'; -const snapshotDir = path.resolve( - __dirname, - '../snapshot-resolver/__snapshots__', -); -const snapshotFile = path.resolve(snapshotDir, 'snapshot.test.js.snap'); +const makeCleanup = (directory: string) => () => { + const snapshotDir = path.resolve(__dirname, `../${directory}/__snapshots__`); + const snapshotFile = path.resolve(snapshotDir, 'snapshot.test.js.snap'); + + if (fs.existsSync(snapshotFile)) { + fs.unlinkSync(snapshotFile); + } + if (fs.existsSync(snapshotDir)) { + fs.rmdirSync(snapshotDir); + } +}; + +const assertResolvesToCustomLocation = (directory: string) => { + const result = runJest(directory, ['-w=1', '--ci=false']); + + expect(result.stderr).toMatch('1 snapshot written from 1 test suite'); + + const content = require( + path.resolve( + __dirname, + `../${directory}/__snapshots__/snapshot.test.js.snap`, + ), + ); + expect(content).toHaveProperty('snapshots are written to custom location 1'); +}; describe('Custom snapshot resolver', () => { - const cleanup = () => { - if (fs.existsSync(snapshotFile)) { - fs.unlinkSync(snapshotFile); - } - if (fs.existsSync(snapshotDir)) { - fs.rmdirSync(snapshotDir); - } - }; + const cleanup = makeCleanup('snapshot-resolver'); beforeEach(cleanup); afterAll(cleanup); it('Resolves snapshot files using custom resolver', () => { - const result = runJest('snapshot-resolver', ['-w=1', '--ci=false']); + assertResolvesToCustomLocation('snapshot-resolver'); + }); +}); - expect(result.stderr).toMatch('1 snapshot written from 1 test suite'); +describe('Custom snapshot resolver written in ESM', () => { + const cleanup = makeCleanup('snapshot-resolver-esm'); - const content = require(snapshotFile); - expect(content).toHaveProperty( - 'snapshots are written to custom location 1', - ); + beforeEach(cleanup); + afterAll(cleanup); + + it('Resolves snapshot files using custom resolver', () => { + assertResolvesToCustomLocation('snapshot-resolver-esm'); }); }); diff --git a/e2e/__tests__/snapshotSerializers.test.ts b/e2e/__tests__/snapshotSerializers.test.ts index 5c0e4543c20d..f390480f4e07 100644 --- a/e2e/__tests__/snapshotSerializers.test.ts +++ b/e2e/__tests__/snapshotSerializers.test.ts @@ -13,14 +13,18 @@ const testDir = path.resolve(__dirname, '../snapshot-serializers'); const snapshotsDir = path.resolve(testDir, '__tests__/__snapshots__'); const snapshotPath = path.resolve(snapshotsDir, 'snapshot.test.js.snap'); -const runAndAssert = () => { - const {exitCode, json} = runWithJson('snapshot-serializers', [ +const esmTestDir = path.resolve(__dirname, '../snapshot-serializers-esm'); +const esmSnapshotsDir = path.resolve(esmTestDir, '__tests__/__snapshots__'); +const esmSnapshotPath = path.resolve(esmSnapshotsDir, 'snapshot.test.js.snap'); + +const runAndAssert = (directory: string, expectedTests: number) => { + const {exitCode, json} = runWithJson(directory, [ '-w=1', '--ci=false', '--no-cache', ]); - expect(json.numTotalTests).toBe(9); - expect(json.numPassedTests).toBe(9); + expect(json.numTotalTests).toBe(expectedTests); + expect(json.numPassedTests).toBe(expectedTests); expect(json.numFailedTests).toBe(0); expect(json.numPendingTests).toBe(0); expect(exitCode).toBe(0); @@ -31,14 +35,30 @@ describe('Snapshot serializers', () => { afterEach(() => cleanup(snapshotsDir)); it('renders snapshot', () => { - runAndAssert(); + runAndAssert('snapshot-serializers', 9); const snapshot = require(snapshotPath); expect(snapshot).toMatchSnapshot(); }); it('compares snapshots correctly', () => { // run twice, second run compares result with snapshot from first run - runAndAssert(); - runAndAssert(); + runAndAssert('snapshot-serializers', 9); + runAndAssert('snapshot-serializers', 9); + }); +}); + +describe('Snapshot serializers written in ESM', () => { + beforeEach(() => cleanup(esmSnapshotsDir)); + afterEach(() => cleanup(esmSnapshotsDir)); + + it('renders snapshot', () => { + runAndAssert('snapshot-serializers-esm', 2); + const snapshot = require(esmSnapshotPath); + expect(snapshot).toMatchSnapshot(); + }); + + it('compares snapshots correctly', () => { + runAndAssert('snapshot-serializers-esm', 2); + runAndAssert('snapshot-serializers-esm', 2); }); }); diff --git a/e2e/__tests__/transform.test.ts b/e2e/__tests__/transform.test.ts index 997e0ba518f8..305045cc1a1f 100644 --- a/e2e/__tests__/transform.test.ts +++ b/e2e/__tests__/transform.test.ts @@ -247,6 +247,39 @@ describe('transform-snapshotResolver', () => { }); }); +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); + } + }; + + beforeEach(cleanupTest); + afterAll(cleanupTest); + + it('should transform the snapshotResolver', () => { + const result = runJest(dir, ['-w=1', '--no-cache', '--ci=false']); + + 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', + ); + }); +}); + describe('transform-environment', () => { const dir = path.resolve(__dirname, '../transform/transform-environment'); 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..5206ec3121f4 --- /dev/null +++ b/e2e/snapshot-resolver-esm/__tests__/snapshot.test.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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..4f8ad2d34759 --- /dev/null +++ b/e2e/snapshot-resolver-esm/customSnapshotResolver.mjs @@ -0,0 +1,18 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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..ab191225eeaa --- /dev/null +++ b/e2e/snapshot-serializers-esm/__tests__/snapshot.test.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +test('uses the first matching serializer', () => { + expect({kind: 'both', value: 'value'}).toMatchSnapshot(); +}); + +test('loads every configured serializer', () => { + expect({kind: 'second', value: 'value'}).toMatchSnapshot(); +}); diff --git a/e2e/snapshot-serializers-esm/package.json b/e2e/snapshot-serializers-esm/package.json new file mode 100644 index 000000000000..2b50e06bff0a --- /dev/null +++ b/e2e/snapshot-serializers-esm/package.json @@ -0,0 +1,9 @@ +{ + "jest": { + "testEnvironment": "node", + "snapshotSerializers": [ + "/plugins/first.mjs", + "/plugins/second.mjs" + ] + } +} diff --git a/e2e/snapshot-serializers-esm/plugins/first.mjs b/e2e/snapshot-serializers-esm/plugins/first.mjs new file mode 100644 index 000000000000..a2916a3e09dd --- /dev/null +++ b/e2e/snapshot-serializers-esm/plugins/first.mjs @@ -0,0 +1,11 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default { + serialize: value => `first: ${value.value}`, + test: value => value?.kind === 'both', +}; diff --git a/e2e/snapshot-serializers-esm/plugins/second.mjs b/e2e/snapshot-serializers-esm/plugins/second.mjs new file mode 100644 index 000000000000..0eb2932fdf26 --- /dev/null +++ b/e2e/snapshot-serializers-esm/plugins/second.mjs @@ -0,0 +1,11 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default { + serialize: value => `second: ${value.value}`, + test: value => value?.kind === 'both' || value?.kind === 'second', +}; 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..5206ec3121f4 --- /dev/null +++ b/e2e/transform/transform-esm-snapshotResolver/__tests__/snapshot.test.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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/babel.config.js b/e2e/transform/transform-esm-snapshotResolver/babel.config.js new file mode 100644 index 000000000000..6a704e126a57 --- /dev/null +++ b/e2e/transform/transform-esm-snapshotResolver/babel.config.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +module.exports = { + presets: [ + [ + '@babel/preset-env', + { + modules: 'commonjs', + targets: {node: 'current'}, + }, + ], + ], +}; diff --git a/e2e/transform/transform-esm-snapshotResolver/customSnapshotResolver.js b/e2e/transform/transform-esm-snapshotResolver/customSnapshotResolver.js new file mode 100644 index 000000000000..4f8ad2d34759 --- /dev/null +++ b/e2e/transform/transform-esm-snapshotResolver/customSnapshotResolver.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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..97737c2e5c58 --- /dev/null +++ b/e2e/transform/transform-esm-snapshotResolver/package.json @@ -0,0 +1,6 @@ +{ + "jest": { + "testEnvironment": "node", + "snapshotResolver": "/customSnapshotResolver.js" + } +} diff --git a/jest.config.mjs b/jest.config.mjs index f50ea6e9347d..d69d0aba863f 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -67,7 +67,7 @@ export default { '/packages/jest-runtime/src/__tests__/defaultResolver.js', '/packages/jest-runtime/src/__tests__/module_dir/', '/packages/jest-runtime/src/__tests__/NODE_PATH_dir', - '/packages/jest-snapshot/src/__tests__/plugins', + '/packages/jest-snapshot/src/__tests__/plugins/', '/packages/jest-snapshot/src/__tests__/fixtures/', '/e2e/__tests__/iterator-to-null-test.ts', '/e2e/__tests__/tsIntegration.test.ts', // this test needs types to be build, it runs in a separate CI job through `jest.config.ts.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 03c27c1b2e55..3061901e4fa4 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts @@ -9,7 +9,7 @@ import type {JestEnvironment} from '@jest/environment'; 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 {type SnapshotState, loadSnapshotSetup} from 'jest-snapshot'; import {deepCyclicCopy} from 'jest-util'; const FRAMEWORK_INITIALIZER = require.resolve('./jestAdapterInit'); @@ -22,6 +22,7 @@ const jestAdapter = async ( testPath: string, sendMessageToJest?: TestFileEvent, ): Promise => { + const snapshotSetup = await loadSnapshotSetup(config); const { collectTestsWithoutRunning, initialize, @@ -34,11 +35,11 @@ const jestAdapter = async ( config, environment, globalConfig, - localRequire: runtime.requireModule.bind(runtime), parentProcess: process, runtime, sendMessageToJest, setGlobalsForRuntime: runtime.setGlobalsForRuntime.bind(runtime), + snapshotSetup, testPath, }); 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 c23f7d76d74c..8df24351a6fe 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts @@ -23,11 +23,7 @@ import { formatResultsErrors, } from 'jest-message-util'; import type Runtime from 'jest-runtime'; -import { - SnapshotState, - addSerializer, - buildSnapshotResolver, -} from 'jest-snapshot'; +import {type SnapshotSetup, SnapshotState, addSerializer} from 'jest-snapshot'; import globals from '..'; import run from '../run'; import {addEventHandler, dispatch, getState as getRunnerState} from '../state'; @@ -57,21 +53,21 @@ export const initialize = async ({ environment, runtime, globalConfig, - localRequire, parentProcess, sendMessageToJest, setGlobalsForRuntime, + snapshotSetup, testPath, }: { config: Config.ProjectConfig; environment: JestEnvironment; runtime: Runtime; globalConfig: Config.GlobalConfig; - localRequire: (path: string) => T; testPath: string; parentProcess: typeof Process; sendMessageToJest?: TestFileEvent; setGlobalsForRuntime: (globals: RuntimeGlobals) => void; + snapshotSetup: SnapshotSetup; }): Promise<{ globals: Global.TestFrameworkGlobals; snapshotState: SnapshotState; @@ -135,12 +131,12 @@ export const initialize = async ({ await dispatch({name: 'include_test_location_in_result'}); } - // 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].reverse()) - addSerializer(localRequire(path)); + const {resolver: snapshotResolver, serializers} = snapshotSetup; + + for (const serializer of serializers) { + addSerializer(serializer); + } - const snapshotResolver = await buildSnapshotResolver(config, localRequire); const snapshotPath = snapshotResolver.resolveSnapshotPath(testPath); const snapshotState = new SnapshotState(snapshotPath, { expand: globalConfig.expand, diff --git a/packages/jest-jasmine2/src/__tests__/setup_jest_globals.test.ts b/packages/jest-jasmine2/src/__tests__/setup_jest_globals.test.ts new file mode 100644 index 000000000000..6621a37fdaa0 --- /dev/null +++ b/packages/jest-jasmine2/src/__tests__/setup_jest_globals.test.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {makeGlobalConfig, makeProjectConfig} from '@jest/test-utils'; +import type {Plugin} from 'pretty-format'; +import {getSerializers} from 'jest-snapshot'; +import setupJestGlobals from '../setup_jest_globals'; + +type GlobalWithJasmine = typeof globalThis & { + jasmine?: { + Spec: new (...args: Array) => unknown; + }; +}; + +it('uses the preloaded snapshot setup', async () => { + const jasmineGlobal = globalThis as GlobalWithJasmine; + const originalJasmine = jasmineGlobal.jasmine; + const serializer: Plugin = { + serialize: () => '', + test: () => false, + }; + const resolveSnapshotPath = jest.fn((testPath: string) => `${testPath}.snap`); + const testPath = '/project/example.test.js'; + + jasmineGlobal.jasmine = {Spec: class {}}; + + try { + const snapshotState = await setupJestGlobals({ + config: makeProjectConfig({rootDir: '/project'}), + globalConfig: makeGlobalConfig(), + snapshotSetup: { + resolver: { + resolveSnapshotPath, + resolveTestPath: snapshotPath => snapshotPath.slice(0, -5), + testPathForConsistencyCheck: testPath, + }, + serializers: [serializer], + }, + testPath, + }); + + expect(resolveSnapshotPath).toHaveBeenCalledWith(testPath); + expect(getSerializers()[0]).toBe(serializer); + expect(expect.getState().snapshotState).toBe(snapshotState); + } finally { + const serializers = getSerializers(); + const serializerIndex = serializers.indexOf(serializer); + if (serializerIndex !== -1) { + serializers.splice(serializerIndex, 1); + } + + if (originalJasmine) { + jasmineGlobal.jasmine = originalJasmine; + } else { + delete jasmineGlobal.jasmine; + } + } +}); diff --git a/packages/jest-jasmine2/src/index.ts b/packages/jest-jasmine2/src/index.ts index cdc0f91605ba..18150cdffffe 100644 --- a/packages/jest-jasmine2/src/index.ts +++ b/packages/jest-jasmine2/src/index.ts @@ -16,7 +16,7 @@ import { } from '@jest/test-result'; import type {Config, Global} from '@jest/types'; import type Runtime from 'jest-runtime'; -import type {SnapshotState} from 'jest-snapshot'; +import {type SnapshotState, loadSnapshotSetup} from 'jest-snapshot'; import {ErrorWithStack} from 'jest-util'; import installEach from './each'; import {installErrorOnPrivate} from './errorOnPrivate'; @@ -298,7 +298,7 @@ export default async function jasmine2( .default({ config, globalConfig, - localRequire: runtime.requireModule.bind(runtime), + snapshotSetup: await loadSnapshotSetup(config), testPath, }); diff --git a/packages/jest-jasmine2/src/setup_jest_globals.ts b/packages/jest-jasmine2/src/setup_jest_globals.ts index df0da3d8557c..94c9276894ec 100644 --- a/packages/jest-jasmine2/src/setup_jest_globals.ts +++ b/packages/jest-jasmine2/src/setup_jest_globals.ts @@ -7,12 +7,7 @@ import {jestExpect} from '@jest/expect'; import type {Config} from '@jest/types'; -import { - SnapshotState, - addSerializer, - buildSnapshotResolver, -} from 'jest-snapshot'; -import type {Plugin} from 'pretty-format'; +import {type SnapshotSetup, SnapshotState, addSerializer} from 'jest-snapshot'; import type { Attributes, default as JasmineSpec, @@ -22,7 +17,7 @@ import type { export type SetupOptions = { config: Config.ProjectConfig; globalConfig: Config.GlobalConfig; - localRequire: (moduleName: string) => Plugin; + snapshotSetup: SnapshotSetup; testPath: string; }; @@ -93,20 +88,17 @@ const patchJasmine = () => { export default async function setupJestGlobals({ config, globalConfig, - localRequire, + snapshotSetup, testPath, }: SetupOptions): Promise { - // 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])); + for (const serializer of snapshotSetup.serializers) { + addSerializer(serializer); } patchJasmine(); const {expand, updateSnapshot} = globalConfig; const {prettierPath, rootDir, snapshotFormat} = config; - const snapshotResolver = await buildSnapshotResolver(config, localRequire); - const snapshotPath = snapshotResolver.resolveSnapshotPath(testPath); + const snapshotPath = snapshotSetup.resolver.resolveSnapshotPath(testPath); const snapshotState = new SnapshotState(snapshotPath, { expand, prettierPath, diff --git a/packages/jest-snapshot/src/SnapshotResolver.ts b/packages/jest-snapshot/src/SnapshotResolver.ts index 96e5f7a769c8..fececf6d41f1 100644 --- a/packages/jest-snapshot/src/SnapshotResolver.ts +++ b/packages/jest-snapshot/src/SnapshotResolver.ts @@ -9,7 +9,6 @@ import * as path from 'node:path'; import chalk from '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,34 +27,36 @@ export const isSnapshotPath = (path: string): boolean => const cache = new Map(); -type LocalRequire = (module: string) => unknown; +type LocalRequire = ( + module: string, + applyInteropRequireDefault?: boolean, +) => Promise; export const buildSnapshotResolver = async ( config: Config.ProjectConfig, - localRequire: Promise | LocalRequire = createTranspilingRequire( - config, - ), + // TODO: Remove this test-only override in Jest 31. + localRequire?: Promise | LocalRequire, ): Promise => { - const key = config.rootDir; + const key = config.id; + const cached = cache.get(key); + + if (cached) { + return cached; + } const resolver = - cache.get(key) ?? - (await createSnapshotResolver(await localRequire, config.snapshotResolver)); + typeof config.snapshotResolver === 'string' + ? await createCustomSnapshotResolver( + config.snapshotResolver, + await (localRequire ?? createTranspilingRequire(config)), + ) + : createDefaultSnapshotResolver(); cache.set(key, resolver); return resolver; }; -async function createSnapshotResolver( - localRequire: LocalRequire, - snapshotResolverPath?: string | null, -): Promise { - return typeof snapshotResolverPath === 'string' - ? createCustomSnapshotResolver(snapshotResolverPath, localRequire) - : createDefaultSnapshotResolver(); -} - function createDefaultSnapshotResolver(): SnapshotResolver { return { resolveSnapshotPath: (testPath: string) => @@ -83,9 +84,10 @@ async function createCustomSnapshotResolver( snapshotResolverPath: string, localRequire: LocalRequire, ): Promise { - const custom: SnapshotResolver = interopRequireDefault( - await localRequire(snapshotResolverPath), - ).default; + const custom = 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..e69cf1fd2d44 100644 --- a/packages/jest-snapshot/src/__tests__/SnapshotResolver.test.ts +++ b/packages/jest-snapshot/src/__tests__/SnapshotResolver.test.ts @@ -15,6 +15,7 @@ import { describe('defaults', () => { let snapshotResolver: SnapshotResolver; const projectConfig = makeProjectConfig({ + id: 'default-resolver', rootDir: 'default', // snapshotResolver: null, }); @@ -50,6 +51,7 @@ describe('custom resolver in project config', () => { 'customSnapshotResolver.js', ); const projectConfig = makeProjectConfig({ + id: 'custom-resolver', rootDir: 'custom1', snapshotResolver: customSnapshotResolverFile, }); @@ -81,6 +83,33 @@ describe('custom resolver in project config', () => { }); }); +it('keeps resolver caches separate for projects sharing a root and resolver', async () => { + const rootDir = 'shared-resolver-root'; + const snapshotResolver = '/shared-resolver.js'; + const createResolver = (suffix: string): SnapshotResolver => ({ + resolveSnapshotPath: testPath => `${testPath}${suffix}`, + resolveTestPath: snapshotPath => snapshotPath.slice(0, -suffix.length), + testPathForConsistencyCheck: 'example.test.js', + }); + const createLocalRequire = + (resolver: SnapshotResolver) => + async (): Promise => + resolver as T; + + const first = await buildSnapshotResolver( + makeProjectConfig({id: 'shared-root-one', rootDir, snapshotResolver}), + createLocalRequire(createResolver('.one')), + ); + const second = await buildSnapshotResolver( + makeProjectConfig({id: 'shared-root-two', rootDir, snapshotResolver}), + createLocalRequire(createResolver('.two')), + ); + + expect(first).not.toBe(second); + expect(first.resolveSnapshotPath('test.js')).toBe('test.js.one'); + expect(second.resolveSnapshotPath('test.js')).toBe('test.js.two'); +}); + describe('malformed custom resolver in project config', () => { const newProjectConfig = (filename: string) => { const customSnapshotResolverFile = path.join( diff --git a/packages/jest-snapshot/src/__tests__/plugins.test.ts b/packages/jest-snapshot/src/__tests__/plugins.test.ts index a091550e5bab..0a9dce13c9cd 100644 --- a/packages/jest-snapshot/src/__tests__/plugins.test.ts +++ b/packages/jest-snapshot/src/__tests__/plugins.test.ts @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +import * as path from 'path'; +import {makeProjectConfig} from '@jest/test-utils'; import type {Plugin} from 'pretty-format'; beforeEach(() => { @@ -31,9 +33,44 @@ const testPath = (names: Array) => { it('gets plugins', () => { const {getSerializers} = require('../plugins') as typeof import('../plugins'); const plugins = getSerializers(); - expect(plugins).toHaveLength(5); + expect(plugins).toHaveLength(7); }); it('adds plugins from an empty array', () => testPath([])); it('adds a single plugin path', () => testPath(['foo'])); it('adds multiple plugin paths', () => testPath(['foo', 'bar'])); + +describe('loadSerializersFromConfig', () => { + const pluginPath = (name: string) => path.resolve(__dirname, 'plugins', name); + + const load = (snapshotSerializers: Array) => { + const {loadSerializersFromConfig} = + require('../plugins') as typeof import('../plugins'); + + return loadSerializersFromConfig( + makeProjectConfig({rootDir: __dirname, snapshotSerializers}), + ); + }; + + it('returns an empty array when no serializer is configured', async () => { + await expect(load([])).resolves.toEqual([]); + }); + + it('loads serializers in reverse order for prepending', async () => { + const serializers = await load([ + pluginPath('foo.js'), + pluginPath('bar.js'), + ]); + + expect(serializers).toEqual([ + require(pluginPath('bar.js')), + require(pluginPath('foo.js')), + ]); + }); + + it('loads the default export from a serializer module', async () => { + const serializers = await load([pluginPath('default.js')]); + + expect(serializers).toEqual([require(pluginPath('default.js')).default]); + }); +}); diff --git a/packages/jest-snapshot/src/__tests__/plugins/default.js b/packages/jest-snapshot/src/__tests__/plugins/default.js new file mode 100644 index 000000000000..11d3e8b41196 --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/plugins/default.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +Object.defineProperty(exports, '__esModule', {value: true}); +exports.default = Symbol(); diff --git a/packages/jest-snapshot/src/index.ts b/packages/jest-snapshot/src/index.ts index f458d4d4f190..f826e6fc7e7c 100644 --- a/packages/jest-snapshot/src/index.ts +++ b/packages/jest-snapshot/src/index.ts @@ -20,7 +20,12 @@ import { stringify, } from 'jest-matcher-utils'; import {isError} from 'jest-util'; -import {EXTENSION, type SnapshotResolver} from './SnapshotResolver'; +import { + EXTENSION, + type SnapshotResolver, + buildSnapshotResolver, +} from './SnapshotResolver'; +import {loadSerializersFromConfig} from './plugins'; import { PROPERTIES_ARG, SNAPSHOT_ARG, @@ -32,7 +37,12 @@ import { printReceived, printSnapshotAndReceived, } from './printSnapshot'; -import type {Context, FileSystem, MatchSnapshotConfig} from './types'; +import type { + Context, + FileSystem, + MatchSnapshotConfig, + SnapshotSetup, +} from './types'; import {deepMerge, serialize} from './utils'; export {addSerializer, getSerializers} from './plugins'; @@ -43,7 +53,14 @@ export { } from './SnapshotResolver'; export type {SnapshotResolver} from './SnapshotResolver'; export {default as SnapshotState} from './State'; -export type {Context, SnapshotMatchers} from './types'; +export type {Context, SnapshotMatchers, SnapshotSetup} from './types'; + +export const loadSnapshotSetup = async ( + config: Config.ProjectConfig, +): Promise => ({ + resolver: await buildSnapshotResolver(config), + serializers: await loadSerializersFromConfig(config), +}); const DID_NOT_THROW = 'Received function did not throw'; // same as toThrow const NOT_SNAPSHOT_MATCHERS = `Snapshot matchers cannot be used with ${BOLD_WEIGHT( diff --git a/packages/jest-snapshot/src/plugins.ts b/packages/jest-snapshot/src/plugins.ts index cc3118c61555..d6b15abd2ca0 100644 --- a/packages/jest-snapshot/src/plugins.ts +++ b/packages/jest-snapshot/src/plugins.ts @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +import {createTranspilingRequire} from '@jest/transform'; +import type {Config} from '@jest/types'; import { type Plugin as PrettyFormatPlugin, type Plugins as PrettyFormatPlugins, @@ -37,3 +39,25 @@ export const addSerializer = (plugin: PrettyFormatPlugin): void => { }; export const getSerializers = (): PrettyFormatPlugins => PLUGINS; + +/** Loads configured serializers in the order expected by addSerializer. */ +export const loadSerializersFromConfig = async ( + config: Config.ProjectConfig, +): Promise => { + if (config.snapshotSerializers.length === 0) { + return []; + } + + const localRequire = await createTranspilingRequire(config); + const serializers: Array = []; + + // Configured serializers run before built-ins and keep their configured order. + // addSerializer prepends, so load them in reverse. + for (const serializerPath of [...config.snapshotSerializers].reverse()) { + serializers.push( + await localRequire(serializerPath, true), + ); + } + + return serializers; +}; diff --git a/packages/jest-snapshot/src/types.ts b/packages/jest-snapshot/src/types.ts index 03eaec78c849..7ccc994ed8f8 100644 --- a/packages/jest-snapshot/src/types.ts +++ b/packages/jest-snapshot/src/types.ts @@ -8,7 +8,11 @@ import type {Expression} from '@babel/types'; import type {MatcherContext} from 'expect'; import type {Frame} from 'jest-message-util'; -import type {PrettyFormatOptions} from 'pretty-format'; +import type { + PrettyFormatOptions, + Plugins as PrettyFormatPlugins, +} from 'pretty-format'; +import type {SnapshotResolver} from './SnapshotResolver'; import type SnapshotState from './State'; export interface Context extends MatcherContext { @@ -81,3 +85,8 @@ export type InlineSnapshot = { frame: Frame; node?: Expression; }; + +export type SnapshotSetup = { + resolver: SnapshotResolver; + serializers: PrettyFormatPlugins; +};