diff --git a/CHANGELOG.md b/CHANGELOG.md index 564e7585c84f..38cf5f91a886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Features - `[jest-config]` Add `defineConfig` and `mergeConfig` helpers for type-safe Jest config ([#15844](https://github.com/jestjs/jest/pull/15844)) +- `[jest-config]` Supports Jest config file with `.mts` extension ([#15796](https://github.com/jestjs/jest/pull/15796)) ### Fixes diff --git a/e2e/__tests__/__snapshots__/jest.config.mts.test.ts.snap b/e2e/__tests__/__snapshots__/jest.config.mts.test.ts.snap new file mode 100644 index 000000000000..f0291e130f68 --- /dev/null +++ b/e2e/__tests__/__snapshots__/jest.config.mts.test.ts.snap @@ -0,0 +1,104 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`do not work with jest.config.mts when TS loader is used 1`] = ` +"Error: Jest: Failed to parse the TypeScript config file <> + Loading .mts Jest config with external loaders is discouraged. + Please use a JS runtime that supports process.features.require_module and process.features.typescript" +`; + +exports[`on node >=22.18.0 invalid JS in jest.config.mts (node with native TS support) 1`] = ` +"Error: Jest: Failed to parse the TypeScript config file <> + SyntaxError [ERR_INVALID_TYPESCRIPT_SYNTAX]: Expected ';', got 'string literal (ll break this file yo, 'll break this file yo)'" +`; + +exports[`on node >=22.18.0 traverses directory tree up until it finds jest.config 1`] = ` +" console.log +<>/jest-config-ts/some/nested/directory + + at Object. (__tests__/a-giraffe.js:3:27) +" +`; + +exports[`on node >=22.18.0 traverses directory tree up until it finds jest.config 2`] = ` +"PASS ../../../__tests__/a-giraffe.js + ✓ giraffe + ✓ abc" +`; + +exports[`on node >=22.18.0 traverses directory tree up until it finds jest.config 3`] = ` +"Test Suites: 1 passed, 1 total +Tests: 2 passed, 2 total +Snapshots: 0 total +Time: <> +Ran all test suites." +`; + +exports[`on node >=22.18.0 work with untyped jest.config.mts for Node versions with default type stripping 1`] = ` +"PASS __tests__/a-giraffe.js + ✓ giraffe" +`; + +exports[`on node >=22.18.0 work with untyped jest.config.mts for Node versions with default type stripping 2`] = ` +"Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: <> +Ran all test suites." +`; + +exports[`on node >=22.18.0 works with tsconfig.json 1`] = ` +"PASS __tests__/a-giraffe.js + ✓ giraffe" +`; + +exports[`on node >=22.18.0 works with tsconfig.json 2`] = ` +"Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: <> +Ran all test suites." +`; + +exports[`on node >=22.18.0 works with typed jest.config.mts 1`] = ` +"PASS __tests__/a-giraffe.js + ✓ giraffe" +`; + +exports[`on node >=22.18.0 works with typed jest.config.mts 2`] = ` +"Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: <> +Ran all test suites." +`; + +exports[`on node >=24 invalid JS in jest.config.mts (node with native TS support) 1`] = ` +"Error: Jest: Failed to parse the TypeScript config file <> + SyntaxError [ERR_INVALID_TYPESCRIPT_SYNTAX]: Expected ';', got 'string literal (ll break this file yo, 'll break this file yo)'" +`; + +exports[`on node ^20.19.0 || >=22.12.0 <22.18.0 does not work with jest.config.mts when require(esm) is not supported 1`] = ` +"Error: Jest: Failed to parse the TypeScript config file <> + Current JS runtime version <> does not support loading .mts Jest config. + Please upgrade your JS runtime to support process.features.require_module" +`; + +exports[`on node ^20.19.0 || >=22.12.0 <22.18.0 does not work with typed jest.config.ts 1`] = ` +"Error: Jest: Failed to parse the TypeScript config file <> + Current JS runtime version <> does not support loading typed .mts Jest config. + Please upgrade your JS runtime to support process.features.typescript + Error: SyntaxError: Unexpected token '{'" +`; + +exports[`on node ^20.19.0 || >=22.12.0 <22.18.0 work with untyped jest.config.mts for Node versions without default type stripping 1`] = ` +"PASS __tests__/a-giraffe.js + ✓ giraffe" +`; + +exports[`on node ^20.19.0 || >=22.12.0 <22.18.0 work with untyped jest.config.mts for Node versions without default type stripping 2`] = ` +"Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: <> +Ran all test suites." +`; diff --git a/e2e/__tests__/jest.config.mts.test.ts b/e2e/__tests__/jest.config.mts.test.ts new file mode 100644 index 000000000000..379a97bfacf7 --- /dev/null +++ b/e2e/__tests__/jest.config.mts.test.ts @@ -0,0 +1,278 @@ +/** + * 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 * as path from 'path'; +import {onNodeVersions} from '@jest/test-utils'; +import {cleanup, extractSummary, writeFiles} from '../Utils'; +import runJest from '../runJest'; + +const DIR = path.resolve(__dirname, '../jest-config-ts'); + +beforeEach(() => cleanup(DIR)); +afterAll(() => cleanup(DIR)); + +test('do not work with jest.config.mts when TS loader is used', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': ` + /** @jest-config-loader esbuild-register */ + import type {Config} from 'jest'; + const config: Config = {testEnvironment: 'jest-environment-node', testRegex: '.*-giraffe.js' }; + export default config; + `, + 'package.json': '{}', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], { + nodeOptions: '--no-warnings', + }); + expect( + stderr + // Remove the stack trace from the error message + .slice(0, Math.max(0, stderr.indexOf('at readConfigFileAndSetRootDir'))) + .trim() + // Replace the path to the config file with a placeholder + .replace( + /(Error: Jest: Failed to parse the TypeScript config file).*$/m, + '$1 <>', + ), + ).toMatchSnapshot(); + expect(exitCode).toBe(1); +}); + +onNodeVersions('^20.19.0 || >=22.12.0 <22.18.0', () => { + test('does not work with jest.config.mts when require(esm) is not supported', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': + "export default {testEnvironment: 'jest-environment-node', testRegex: '.*-giraffe.js'};", + 'package.json': '{}', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], { + nodeOptions: '--no-warnings --no-experimental-require-module', + }); + expect( + stderr + // Remove the stack trace from the error message + .slice(0, Math.max(0, stderr.indexOf('at readConfigFileAndSetRootDir'))) + .trim() + // Replace the path to the config file with a placeholder + .replace( + /(Error: Jest: Failed to parse the TypeScript config file).*$/m, + '$1 <>', + ) + // Replace Node version with placeholder + .replace(/(Current JS runtime version) (.+?) /m, '$1 <> '), + ).toMatchSnapshot(); + expect(exitCode).toBe(1); + }); + + test('work with untyped jest.config.mts for Node versions without default type stripping', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': + "export default {testEnvironment: 'jest-environment-node', testRegex: '.*-giraffe.js'};", + 'package.json': '{}', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], { + nodeOptions: '--no-warnings', + }); + const {rest, summary} = extractSummary(stderr); + expect(exitCode).toBe(0); + expect(rest).toMatchSnapshot(); + expect(summary).toMatchSnapshot(); + }); + + test('does not work with typed jest.config.ts', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': ` + import type {Config} from 'jest'; + const config: Config = {testEnvironment: 'jest-environment-node', testRegex: '.*-giraffe.js' }; + export default config; + `, + 'package.json': '{}', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], { + nodeOptions: '--no-warnings', + }); + expect( + stderr + // Remove the stack trace from the error message + .slice(0, Math.max(0, stderr.indexOf('at readConfigFileAndSetRootDir'))) + .trim() + // Replace the path to the config file with a placeholder + .replace( + /(Error: Jest: Failed to parse the TypeScript config file).*$/m, + '$1 <>', + ) + // Replace Node version with + .replace(/(Current JS runtime version) (.+?) /m, '$1 <> '), + ).toMatchSnapshot(); + expect(exitCode).toBe(1); + }); + + test('invalid JS in jest.config.mts', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': "export default i'll break this file yo", + 'package.json': '{}', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false']); + expect(stderr).toMatch('does not support loading typed .mts Jest config'); + expect(stderr).toMatch( + 'Please upgrade your JS runtime to support process.features.typescript', + ); + expect(exitCode).toBe(1); + }); +}); + +onNodeVersions('>=22.18.0', () => { + test('work with untyped jest.config.mts for Node versions with default type stripping', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': + "export default {testEnvironment: 'jest-environment-node', testRegex: '.*-giraffe.js'};", + 'package.json': '{}', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], { + nodeOptions: '--no-warnings', + }); + const {rest, summary} = extractSummary(stderr); + expect(exitCode).toBe(0); + expect(rest).toMatchSnapshot(); + expect(summary).toMatchSnapshot(); + }); + + test('works with typed jest.config.mts', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': ` + import type {Config} from 'jest'; + const config: Config = {testEnvironment: 'jest-environment-node', testRegex: '.*-giraffe.js' }; + export default config; + `, + 'package.json': '{"type": "commonjs"}', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], { + nodeOptions: '--no-warnings', + }); + const {rest, summary} = extractSummary(stderr); + expect(exitCode).toBe(0); + expect(rest).toMatchSnapshot(); + expect(summary).toMatchSnapshot(); + }); + + test('works with tsconfig.json', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': + "export default {testEnvironment: 'jest-environment-node', testRegex: '.*-giraffe.js'};", + 'package.json': '{}', + 'tsconfig.json': '{ "compilerOptions": { "module": "esnext" } }', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], { + nodeOptions: '--no-warnings', + }); + const {rest, summary} = extractSummary(stderr); + expect(exitCode).toBe(0); + expect(rest).toMatchSnapshot(); + expect(summary).toMatchSnapshot(); + }); + + test('traverses directory tree up until it finds jest.config', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': ` + const slash = require('slash'); + test('giraffe', () => expect(1).toBe(1)); + test('abc', () => console.log(slash(process.cwd()))); + `, + 'jest.config.mts': + "export default {testEnvironment: 'jest-environment-node', testRegex: '.*-giraffe.js'};", + 'package.json': '{}', + 'some/nested/directory/file.js': '// nothing special', + }); + + const {stderr, exitCode, stdout} = runJest( + path.join(DIR, 'some', 'nested', 'directory'), + ['-w=1', '--ci=false'], + {nodeOptions: '--no-warnings', skipPkgJsonCheck: true}, + ); + + // Snapshot the console.logged `process.cwd()` and make sure it stays the same + expect( + stdout + .replaceAll(/^\W+(.*)e2e/gm, '<>') + // slightly different log in node versions >= 23 + .replace('at Object.log', 'at Object.'), + ).toMatchSnapshot(); + + const {rest, summary} = extractSummary(stderr); + expect(exitCode).toBe(0); + expect(rest).toMatchSnapshot(); + expect(summary).toMatchSnapshot(); + }); + + test('invalid JS in jest.config.mts (node with native TS support)', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': "export default i'll break this file yo", + 'package.json': '{}', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], { + nodeOptions: '--no-warnings', + }); + expect( + stderr + // Remove the stack trace from the error message + .slice(0, Math.max(0, stderr.indexOf('at readConfigFileAndSetRootDir'))) + .trim() + // Replace the path to the config file with a placeholder + .replace( + /(Error: Jest: Failed to parse the TypeScript config file).*$/m, + '$1 <>', + ), + ).toMatchSnapshot(); + expect(exitCode).toBe(1); + }); +}); + +onNodeVersions('>=24', () => { + // todo fixme + // eslint-disable-next-line jest/no-identical-title + test('invalid JS in jest.config.mts (node with native TS support)', () => { + writeFiles(DIR, { + '__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));", + 'jest.config.mts': "export default i'll break this file yo", + 'package.json': '{}', + }); + + const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], { + nodeOptions: '--no-warnings', + }); + expect( + stderr + // Remove the stack trace from the error message + .slice(0, Math.max(0, stderr.indexOf('at readConfigFileAndSetRootDir'))) + .trim() + // Replace the path to the config file with a placeholder + .replace( + /(Error: Jest: Failed to parse the TypeScript config file).*$/m, + '$1 <>', + ), + ).toMatchSnapshot(); + expect(exitCode).toBe(1); + }); +}); diff --git a/packages/create-jest/src/__tests__/__fixtures__/has-jest-config-file-mts/jest.config.mts b/packages/create-jest/src/__tests__/__fixtures__/has-jest-config-file-mts/jest.config.mts new file mode 100644 index 000000000000..4f69b4e3bda0 --- /dev/null +++ b/packages/create-jest/src/__tests__/__fixtures__/has-jest-config-file-mts/jest.config.mts @@ -0,0 +1,8 @@ +/** + * 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 {}; diff --git a/packages/create-jest/src/__tests__/__fixtures__/has-jest-config-file-mts/package.json b/packages/create-jest/src/__tests__/__fixtures__/has-jest-config-file-mts/package.json new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/packages/create-jest/src/__tests__/__fixtures__/has-jest-config-file-mts/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/create-jest/src/__tests__/__snapshots__/init.test.ts.snap b/packages/create-jest/src/__tests__/__snapshots__/init.test.ts.snap index b81a676fa4a2..63a3c27027ca 100644 --- a/packages/create-jest/src/__tests__/__snapshots__/init.test.ts.snap +++ b/packages/create-jest/src/__tests__/__snapshots__/init.test.ts.snap @@ -54,6 +54,15 @@ Object { } `; +exports[`init has-jest-config-file-mts ask the user whether to override config or not user answered with "Yes" 1`] = ` +Object { + "initial": true, + "message": "It seems that you already have a jest configuration, do you want to override it?", + "name": "continue", + "type": "confirm", +} +`; + exports[`init has-jest-config-file-ts ask the user whether to override config or not user answered with "Yes" 1`] = ` Object { "initial": true, diff --git a/packages/jest-cli/src/__tests__/args.test.ts b/packages/jest-cli/src/__tests__/args.test.ts index 9cc7994205e1..52302fda8f0a 100644 --- a/packages/jest-cli/src/__tests__/args.test.ts +++ b/packages/jest-cli/src/__tests__/args.test.ts @@ -89,13 +89,16 @@ describe('check', () => { it('raises an exception if config is not a valid JSON string', () => { expect(() => check(argv({config: 'x:1'}))).toThrow( - 'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .cjs, .cts, .json', + `The --config option requires a JSON string literal, or a file path with one of these extensions: ${constants.JEST_CONFIG_EXT_ORDER.join( + ', ', + )}`, ); }); it('raises an exception if config is not a supported file type', () => { - const message = - 'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .cjs, .cts, .json'; + const message = `The --config option requires a JSON string literal, or a file path with one of these extensions: ${constants.JEST_CONFIG_EXT_ORDER.join( + ', ', + )}`; expect(() => check(argv({config: 'jest.configjs'}))).toThrow(message); expect(() => check(argv({config: 'jest.config.exe'}))).toThrow(message); diff --git a/packages/jest-config/src/constants.ts b/packages/jest-config/src/constants.ts index 63172c139250..13ceb1128ae2 100644 --- a/packages/jest-config/src/constants.ts +++ b/packages/jest-config/src/constants.ts @@ -13,6 +13,7 @@ export const PACKAGE_JSON = 'package.json'; export const JEST_CONFIG_BASE_NAME = 'jest.config'; export const JEST_CONFIG_EXT_CJS = '.cjs'; export const JEST_CONFIG_EXT_MJS = '.mjs'; +export const JEST_CONFIG_EXT_MTS = '.mts'; export const JEST_CONFIG_EXT_JS = '.js'; export const JEST_CONFIG_EXT_TS = '.ts'; export const JEST_CONFIG_EXT_CTS = '.cts'; @@ -21,6 +22,7 @@ export const JEST_CONFIG_EXT_ORDER = Object.freeze([ JEST_CONFIG_EXT_JS, JEST_CONFIG_EXT_TS, JEST_CONFIG_EXT_MJS, + JEST_CONFIG_EXT_MTS, JEST_CONFIG_EXT_CJS, JEST_CONFIG_EXT_CTS, JEST_CONFIG_EXT_JSON, diff --git a/packages/jest-config/src/readConfigFileAndSetRootDir.ts b/packages/jest-config/src/readConfigFileAndSetRootDir.ts index 1648afa1843e..13b6770d6230 100644 --- a/packages/jest-config/src/readConfigFileAndSetRootDir.ts +++ b/packages/jest-config/src/readConfigFileAndSetRootDir.ts @@ -16,6 +16,7 @@ import {interopRequireDefault, requireOrImportModule} from 'jest-util'; import { JEST_CONFIG_EXT_CTS, JEST_CONFIG_EXT_JSON, + JEST_CONFIG_EXT_MTS, JEST_CONFIG_EXT_TS, PACKAGE_JSON, } from './constants'; @@ -32,6 +33,7 @@ type TsLoaderModule = 'ts-node' | 'esbuild-register'; export default async function readConfigFileAndSetRootDir( configPath: string, ): Promise { + const isMTS = configPath.endsWith(JEST_CONFIG_EXT_MTS); const isTS = configPath.endsWith(JEST_CONFIG_EXT_TS) || configPath.endsWith(JEST_CONFIG_EXT_CTS); @@ -75,6 +77,47 @@ export default async function readConfigFileAndSetRootDir( } else { configObject = await loadTSConfigFile(configPath); } + } else if (isMTS) { + // JS runtime's support for Typescript is mature enough that we should + // guide users towards native usage instead of possibly un-maintained + // external loader. + if (hasTsLoaderExplicitlyConfigured(configPath)) { + // eslint-disable-next-line no-throw-literal + throw ( + ' Loading .mts Jest config with external loaders is discouraged.\n' + + ' Please use a JS runtime that supports process.features.require_module and process.features.typescript' + ); + } else { + // @ts-expect-error: Type assertion can be removed once @types/node is updated to 23 https://nodejs.org/api/process.html#processfeaturesrequire_module + if (!process.features.require_module) { + // Likely JS runtime does not yet support require(esm) yet. + // This string is caught further down and merged into a new error message. + // eslint-disable-next-line no-throw-literal + throw ( + ` Current JS runtime version ${process.versions.node} does not support loading .mts Jest config.\n` + + ' Please upgrade your JS runtime to support process.features.require_module' + ); + } + + // Relies on import(.mts) before falling back to require(.mts) + try { + configObject = await requireOrImportModule(configPath); + } catch (requireOrImportModuleError) { + // Likely JS runtime does not support type stripping when require(esm). + // @ts-expect-error: Type assertion can be removed once @types/node is updated to 23 https://nodejs.org/api/process.html#processfeaturestypescript + if (!process.features.typescript) { + // This string is caught further down and merged into a new error message. + // eslint-disable-next-line no-throw-literal + throw ( + ` Current JS runtime version ${process.versions.node} does not support loading typed .mts Jest config.\n` + + ' Please upgrade your JS runtime to support process.features.typescript \n' + + ` Error: ${requireOrImportModuleError}\n` + ); + } + // Encounter unknown errors, thrown to users for further debugging. + throw requireOrImportModuleError; + } + } } else if (isJSON) { const fileContent = fs.readFileSync(configPath, 'utf8'); configObject = parseJson(stripJsonComments(fileContent), configPath); @@ -82,7 +125,7 @@ export default async function readConfigFileAndSetRootDir( configObject = await requireOrImportModule(configPath); } } catch (error) { - if (isTS) { + if (isTS || isMTS) { throw new Error( `Jest: Failed to parse the TypeScript config file ${configPath}\n` + ` ${error}`,