diff --git a/CHANGELOG.md b/CHANGELOG.md index 591d4227de01..58bb235af65a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ## Features - `[jest-environment-jsdom-abstract]` Add support for JSDOM v27 ([#15834](https://github.com/jestjs/jest/pull/15834)) +- `[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..9ba2aa96aab7 --- /dev/null +++ b/e2e/__tests__/__snapshots__/jest.config.mts.test.ts.snap @@ -0,0 +1,120 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`on node <20.19.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 Node version <> does not support loading .mts Jest config. + Please upgrade to ^20.19.0 || >=22.12.0" +`; + +exports[`on node >=22.18.0 || ^23.6 invalid JS in jest.config.mts (node with native TS support) 1`] = ` +"Error: Jest: Failed to parse the TypeScript config file <> + both with the native node TypeScript support and configured TypeScript loaders. + Errors were: + - SyntaxError [ERR_INVALID_TYPESCRIPT_SYNTAX]: x Expected ';', got 'string literal (ll break this file yo, 'll break this file yo)' + ,---- + 1 | export default i'll break this file yo + : ^^^^^^^^^^^^^^^^^^^^^^ + \`---- + x Unterminated string constant + ,---- + 1 | export default i'll break this file yo + : ^^^^^^^^^^^^^^^^^^^^^^ + \`---- + + - TSError: ⨯ Unable to compile TypeScript: +jest.config.mts(1,16): error TS2304: Cannot find name 'i'. +jest.config.mts(1,17): error TS1005: ';' expected. +jest.config.mts(1,39): error TS1002: Unterminated string literal." +`; + +exports[`on node >=22.18.0 || ^23.6 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 || ^23.6 traverses directory tree up until it finds jest.config 2`] = ` +"PASS ../../../__tests__/a-giraffe.js + ✓ giraffe + ✓ abc" +`; + +exports[`on node >=22.18.0 || ^23.6 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 || ^23.6 work with untyped jest.config.mts 1`] = ` +"PASS __tests__/a-giraffe.js + ✓ giraffe" +`; + +exports[`on node >=22.18.0 || ^23.6 work with untyped 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 >=22.18.0 || ^23.6 works with tsconfig.json 1`] = ` +"PASS __tests__/a-giraffe.js + ✓ giraffe" +`; + +exports[`on node >=22.18.0 || ^23.6 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 || ^23.6 works with typed jest.config.mts 1`] = ` +"PASS __tests__/a-giraffe.js + ✓ giraffe" +`; + +exports[`on node >=22.18.0 || ^23.6 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 <> + both with the native node TypeScript support and configured TypeScript loaders. + Errors were: + - SyntaxError [ERR_INVALID_TYPESCRIPT_SYNTAX]: Expected ';', got 'string literal (ll break this file yo, 'll break this file yo)' + - TSError: ⨯ Unable to compile TypeScript: +jest.config.mts(1,16): error TS2304: Cannot find name 'i'. +jest.config.mts(1,17): error TS1005: ';' expected. +jest.config.mts(1,39): error TS1002: Unterminated string literal." +`; + +exports[`on node ^20.19.0 || >=22.12.0 <22.18.0 || 23 - 23.6.0 does not work with typed jest.config.ts 1`] = ` +"Error: Jest: Failed to parse the TypeScript config file <> + Current Node version <> does not support loading typed .mts Jest config. + Please upgrade to >=22.18.0 || ^23.6 + Error: SyntaxError: Missing initializer in const declaration" +`; + +exports[`on node ^20.19.0 || >=22.12.0 <22.18.0 || 23 - 23.6.0 work with untyped jest.config.mts 1`] = ` +"PASS __tests__/a-giraffe.js + ✓ giraffe" +`; + +exports[`on node ^20.19.0 || >=22.12.0 <22.18.0 || 23 - 23.6.0 work with untyped jest.config.mts 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..d7a02e738c92 --- /dev/null +++ b/e2e/__tests__/jest.config.mts.test.ts @@ -0,0 +1,248 @@ +/** + * 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)); + +onNodeVersions('<20.19.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', + }); + 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 Node version) (.+?) /m, '$1 <> '), + ).toMatchSnapshot(); + expect(exitCode).toBe(1); + }); +}); + +onNodeVersions('^20.19.0 || >=22.12.0 <22.18.0 || 23 - 23.6.0', () => { + test('work with untyped jest.config.mts', () => { + 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 {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 Node 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('SyntaxError: Invalid or unexpected token'); + expect(exitCode).toBe(1); + }); +}); + +onNodeVersions('>=22.18.0 || ^23.6', () => { + test('work with untyped jest.config.mts', () => { + 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 {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/jest-config/package.json b/packages/jest-config/package.json index 8123fe6767e7..9dadf3a6319b 100644 --- a/packages/jest-config/package.json +++ b/packages/jest-config/package.json @@ -57,6 +57,7 @@ "micromatch": "^4.0.8", "parse-json": "^5.2.0", "pretty-format": "workspace:*", + "semver": "^7.7.2", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -67,7 +68,6 @@ "@types/parse-json": "^4.0.2", "esbuild": "^0.25.5", "esbuild-register": "^3.6.0", - "semver": "^7.7.2", "ts-node": "^10.5.0", "typescript": "^5.8.3" }, 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..161fb5fb097d 100644 --- a/packages/jest-config/src/readConfigFileAndSetRootDir.ts +++ b/packages/jest-config/src/readConfigFileAndSetRootDir.ts @@ -16,9 +16,11 @@ 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'; +import {satisfies} from 'semver'; interface TsLoader { enabled: (bool: boolean) => void; @@ -32,9 +34,11 @@ 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); + configPath.endsWith(JEST_CONFIG_EXT_CTS) || + isMTS; const isJSON = configPath.endsWith(JEST_CONFIG_EXT_JSON); let configObject; @@ -73,7 +77,37 @@ export default async function readConfigFileAndSetRootDir( } } } else { - configObject = await loadTSConfigFile(configPath); + if (isMTS) { + // TODO: remove this once dropping Node 20/22 support. + const mtsExtSupportVersionRange = '^20.19.0 || >=22.12.0'; + if (!satisfies(process.versions.node, mtsExtSupportVersionRange)) { + // Likely Node version not yet supports require(esm) + // This string is caught further down and merged into a new error message. + // eslint-disable-next-line no-throw-literal + throw ( + ` Current Node version ${process.versions.node} does not support loading .mts Jest config.\n` + + ` Please upgrade to ${mtsExtSupportVersionRange}` + ); + } + // Relies on import(.mts) before falling back to require(.mts) + try { + configObject = await requireOrImportModule(configPath); + } catch (requireOrImportModuleError) { + if (!(requireOrImportModuleError instanceof SyntaxError)) { + throw requireOrImportModuleError; + } + // Likely Node version does not support type stripping when require(esm). + // This string is caught further down and merged into a new error message. + // eslint-disable-next-line no-throw-literal + throw ( + ` Current Node version ${process.versions.node} does not support loading typed .mts Jest config.\n` + + ' Please upgrade to >=22.18.0 || ^23.6\n' + + ` Error: ${requireOrImportModuleError}\n` + ); + } + } else { + configObject = await loadTSConfigFile(configPath); + } } } else if (isJSON) { const fileContent = fs.readFileSync(configPath, 'utf8');