Skip to content

Commit

Permalink
feat(config): if process.features.typescript is set, load `jest.con…
Browse files Browse the repository at this point in the history
…fig.ts` without external loader (#15480)

if `process.features.typescript` is set, load `jest.config.ts` without external loader

see https://nodejs.org/api/process.html#processfeaturestypescript
This value is set if node is run with `--experimental-transform-types` or `--experimental-strip-types` so no external package is required for transpilation in those cases
  • Loading branch information
phryneas authored Jan 30, 2025
1 parent 7ea9a40 commit d336118
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 64 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
- `[jest-config]` Allow loading `jest.config.cts` files ([#14070](https://github.com/facebook/jest/pull/14070))
- `[jest-config]` Show `rootDir` in error message when a `preset` fails to load ([#15194](https://github.com/jestjs/jest/pull/15194))
- `[jest-config]` Support loading TS config files using `esbuild-register` via docblock loader ([#15190](https://github.com/jestjs/jest/pull/15190))
- `[jest-config]` allow passing TS config loader options via docblock comment ([#15234](https://github.com/jestjs/jest/pull/15234))
- `[jest-config]` Allow passing TS config loader options via docblock comment ([#15234](https://github.com/jestjs/jest/pull/15234))
- `[jest-config]` If Node is running with type stripping enabled, do not require a TS loader ([#15480](https://github.com/jestjs/jest/pull/15480))
- `[@jest/core]` Group together open handles with the same stack trace ([#13417](https://github.com/jestjs/jest/pull/13417), & [#14789](https://github.com/jestjs/jest/pull/14789))
- `[@jest/core]` Add `perfStats` to surface test setup overhead ([#14622](https://github.com/jestjs/jest/pull/14622))
- `[@jest/core]` [**BREAKING**] Changed `--filter` to accept an object with shape `{ filtered: Array<string> }` to match [documentation](https://jestjs.io/docs/cli#--filterfile) ([#13319](https://github.com/jestjs/jest/pull/13319))
Expand Down
14 changes: 14 additions & 0 deletions e2e/__tests__/__snapshots__/jest.config.ts.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`on node >=23.6 invalid JS in jest.config.ts (node with native TS support) 1`] = `
"Error: Jest: Failed to parse the TypeScript config file <<REPLACED>>
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
: ^^^^^^^^^^^^^^^^^^^^^^
\`----"
`;
exports[`traverses directory tree up until it finds jest.config 1`] = `
" console.log
<<REPLACED>>/jest-config-ts/some/nested/directory
Expand Down
116 changes: 77 additions & 39 deletions e2e/__tests__/jest.config.ts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import * as path from 'path';
import * as fs from 'graceful-fs';
import {onNodeVersions} from '@jest/test-utils';
import {cleanup, extractSummary, writeFiles} from '../Utils';
import runJest from '../runJest';

Expand All @@ -23,7 +24,9 @@ test('works with jest.config.ts', () => {
'package.json': '{}',
});

const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false']);
const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], {
nodeOptions: '--no-warnings',
});
const {rest, summary} = extractSummary(stderr);
expect(exitCode).toBe(0);
expect(rest).toMatchSnapshot();
Expand All @@ -39,7 +42,9 @@ test('works with tsconfig.json', () => {
'tsconfig.json': '{ "compilerOptions": { "module": "esnext" } }',
});

const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false']);
const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false'], {
nodeOptions: '--no-warnings',
});
const {rest, summary} = extractSummary(stderr);
expect(exitCode).toBe(0);
expect(rest).toMatchSnapshot();
Expand All @@ -62,62 +67,95 @@ test('traverses directory tree up until it finds jest.config', () => {
const {stderr, exitCode, stdout} = runJest(
path.join(DIR, 'some', 'nested', 'directory'),
['-w=1', '--ci=false'],
{skipPkgJsonCheck: true},
{nodeOptions: '--no-warnings', skipPkgJsonCheck: true},
);

// Snapshot the console.logged `process.cwd()` and make sure it stays the same
expect(stdout.replaceAll(/^\W+(.*)e2e/gm, '<<REPLACED>>')).toMatchSnapshot();
expect(
stdout
.replaceAll(/^\W+(.*)e2e/gm, '<<REPLACED>>')
// slightly different log in node versions >= 23
.replace('at Object.log', 'at Object.<anonymous>'),
).toMatchSnapshot();

const {rest, summary} = extractSummary(stderr);
expect(exitCode).toBe(0);
expect(rest).toMatchSnapshot();
expect(summary).toMatchSnapshot();
});

const jestPath = require.resolve('jest');
const jestTypesPath = jestPath.replace(/\.js$/, '.d.ts');
const jestTypesExists = fs.existsSync(jestTypesPath);
onNodeVersions('<23.6', () => {
const jestPath = require.resolve('jest');
const jestTypesPath = jestPath.replace(/\.js$/, '.d.ts');
const jestTypesExists = fs.existsSync(jestTypesPath);

(jestTypesExists ? test : test.skip).each([true, false])(
'check the config disabled (skip type check: %p)',
skipTypeCheck => {
writeFiles(DIR, {
'__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));",
'jest.config.ts': `
/**@jest-config-loader-options {"transpileOnly":${!!skipTypeCheck}}*/
import {Config} from 'jest';
const config: Config = { testTimeout: "10000" };
export default config;
`,
'package.json': '{}',
});

const typeErrorString =
"TS2322: Type 'string' is not assignable to type 'number'.";
const runtimeErrorString = 'Option "testTimeout" must be of type:';

const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false']);

if (skipTypeCheck) {
expect(stderr).not.toMatch(typeErrorString);
expect(stderr).toMatch(runtimeErrorString);
} else {
expect(stderr).toMatch(typeErrorString);
expect(stderr).not.toMatch(runtimeErrorString);
}

expect(exitCode).toBe(1);
},
);

(jestTypesExists ? test : test.skip).each([true, false])(
'check the config disabled (skip type check: %p)',
skipTypeCheck => {
test('invalid JS in jest.config.ts', () => {
writeFiles(DIR, {
'__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));",
'jest.config.ts': `
/**@jest-config-loader-options {"transpileOnly":${!!skipTypeCheck}}*/
import {Config} from 'jest';
const config: Config = { testTimeout: "10000" };
export default config;
`,
'jest.config.ts': "export default i'll break this file yo",
'package.json': '{}',
});

const typeErrorString =
"TS2322: Type 'string' is not assignable to type 'number'.";
const runtimeErrorString = 'Option "testTimeout" must be of type:';

const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false']);
expect(stderr).toMatch('TSError: ⨯ Unable to compile TypeScript:');
expect(exitCode).toBe(1);
});
});

if (skipTypeCheck) {
expect(stderr).not.toMatch(typeErrorString);
expect(stderr).toMatch(runtimeErrorString);
} else {
expect(stderr).toMatch(typeErrorString);
expect(stderr).not.toMatch(runtimeErrorString);
}
onNodeVersions('>=23.6', () => {
test('invalid JS in jest.config.ts (node with native TS support)', () => {
writeFiles(DIR, {
'__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));",
'jest.config.ts': "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('Caused by')))
.trim()
// Replace the path to the config file with a placeholder
.replace(
/(Error: Jest: Failed to parse the TypeScript config file).*$/m,
'$1 <<REPLACED>>',
),
).toMatchSnapshot();
expect(exitCode).toBe(1);
},
);

test('invalid JS in jest.config.ts', () => {
writeFiles(DIR, {
'__tests__/a-giraffe.js': "test('giraffe', () => expect(1).toBe(1));",
'jest.config.ts': "export default i'll break this file yo",
'package.json': '{}',
});

const {stderr, exitCode} = runJest(DIR, ['-w=1', '--ci=false']);
expect(stderr).toMatch('TSError: ⨯ Unable to compile TypeScript:');
expect(exitCode).toBe(1);
});
19 changes: 11 additions & 8 deletions e2e/__tests__/readInitialOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
import path = require('path');
import execa = require('execa');
import {onNodeVersions} from '@jest/test-utils';
import type {ReadJestConfigOptions, readInitialOptions} from 'jest-config';

function resolveFixture(...pathSegments: Array<string>) {
Expand Down Expand Up @@ -88,14 +89,16 @@ describe('readInitialOptions', () => {
expect(configPath).toEqual(expectedConfigFile);
});

test('should give an error when using unsupported loader', async () => {
const cwd = resolveFixture('ts-loader-config');
const error: Error = await proxyReadInitialOptions(undefined, {cwd}).catch(
error => error,
);
expect(error.message).toContain(
"Jest: 'ts-loader' is not a valid TypeScript configuration loader.",
);
onNodeVersions('<22.6', () => {
test('should give an error when using unsupported loader', async () => {
const cwd = resolveFixture('ts-loader-config');
const error: Error = await proxyReadInitialOptions(undefined, {
cwd,
}).catch(error => error);
expect(error.message).toContain(
"Jest: 'ts-loader' is not a valid TypeScript configuration loader.",
);
});
});

test('should give an error when there are multiple config files', async () => {
Expand Down
36 changes: 21 additions & 15 deletions e2e/__tests__/typescriptConfigFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@

import {tmpdir} from 'os';
import * as path from 'path';
import * as semver from 'semver';
import {onNodeVersions} from '@jest/test-utils';
import {cleanup, writeFiles} from '../Utils';
import runJest, {getConfig} from '../runJest';

const DIR = path.resolve(tmpdir(), 'typescript-config-file');
const useNativeTypeScript = semver.satisfies(process.versions.node, '>=23.6.0');
const importFileExtension = useNativeTypeScript ? '.ts' : '';

beforeEach(() => cleanup(DIR));
afterEach(() => cleanup(DIR));
Expand All @@ -20,7 +24,7 @@ test('works with single typescript config that imports something', () => {
'__tests__/mytest.alpha.js': "test('alpha', () => expect(1).toBe(1));",
'__tests__/mytest.common.js': "test('common', () => expect(1).toBe(1));",
'alpha.config.ts': `
import commonRegex from './common';
import commonRegex from './common${importFileExtension}';
export default {
testRegex: [ commonRegex, '__tests__/mytest.alpha.js' ]
};`,
Expand Down Expand Up @@ -77,12 +81,12 @@ test('works with multiple typescript configs that import something', () => {
'__tests__/mytest.beta.js': "test('beta', () => expect(1).toBe(1));",
'__tests__/mytest.common.js': "test('common', () => expect(1).toBe(1));",
'alpha.config.ts': `
import commonRegex from './common';
import commonRegex from './common${importFileExtension}';
export default {
testRegex: [ commonRegex, '__tests__/mytest.alpha.js' ]
};`,
'beta.config.ts': `
import commonRegex from './common';
import commonRegex from './common${importFileExtension}';
export default {
testRegex: [ commonRegex, '__tests__/mytest.beta.js' ]
};`,
Expand All @@ -108,18 +112,20 @@ test('works with multiple typescript configs that import something', () => {
expect(stdout).toBe('');
});

test("works with single typescript config that does not import anything with project's moduleResolution set to Node16", () => {
const {configs} = getConfig(
'typescript-config/modern-module-resolution',
[],
{
skipPkgJsonCheck: true,
},
);
onNodeVersions('<23.6', () => {
test("works with single typescript config that does not import anything with project's moduleResolution set to Node16", () => {
const {configs} = getConfig(
'typescript-config/modern-module-resolution',
[],
{
skipPkgJsonCheck: true,
},
);

expect(configs).toHaveLength(1);
expect(configs[0].displayName).toEqual({
color: 'white',
name: 'Config from modern ts file',
expect(configs).toHaveLength(1);
expect(configs[0].displayName).toEqual({
color: 'white',
name: 'Config from modern ts file',
});
});
});
6 changes: 5 additions & 1 deletion packages/jest-config/src/readConfigFileAndSetRootDir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@ export default async function readConfigFileAndSetRootDir(
configPath.endsWith(JEST_CONFIG_EXT_TS) ||
configPath.endsWith(JEST_CONFIG_EXT_CTS);
const isJSON = configPath.endsWith(JEST_CONFIG_EXT_JSON);
// type assertion can be removed once @types/node is updated
// https://nodejs.org/api/process.html#processfeaturestypescript
const supportsTS = (process.features as {typescript?: boolean | string})
.typescript;
let configObject;

try {
if (isTS) {
if (isTS && !supportsTS) {
configObject = await loadTSConfigFile(configPath);
} else if (isJSON) {
const fileContent = fs.readFileSync(configPath, 'utf8');
Expand Down

0 comments on commit d336118

Please sign in to comment.