Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(config): if process.features.typescript is set, load jest.config.ts without external loader #15480

Merged
merged 13 commits into from
Jan 30, 2025
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)',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping this test in new node versions since the node feature only transpiles and doesn't type check.

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', () => {
Copy link
Contributor Author

@phryneas phryneas Jan 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diff is a bit weird here - this test is duplicated into a pre-23.6 and post-23.6 version now.

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}';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transpiled resolution is fine with './common', type-stripped node execution wants './common.ts'.

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', () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node resolution modes cannot be configured via tsconfig.json in modern node TS - it just uses modern node resolution by default.

This test fails anyways, because it requires transpilation to happen - the package.json contains type: "commonjs" and the jest.config.ts is written in ESM style.
That fails native node. It would work with type: "module", but we wouldn't really test what this test is about at this point - so I'm skipping it.

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 @@
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})

Check warning on line 41 in packages/jest-config/src/readConfigFileAndSetRootDir.ts

View check run for this annotation

Codecov / codecov/patch

packages/jest-config/src/readConfigFileAndSetRootDir.ts#L41

Added line #L41 was not covered by tests
.typescript;
let configObject;

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