Skip to content

Commit d4fe7ec

Browse files
committed
feat: support files other than js to be ESM
1 parent dbb1290 commit d4fe7ec

File tree

25 files changed

+285
-30
lines changed

25 files changed

+285
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
### Features
44

55
- `[jest-config]` [**BREAKING**] Default to Node testing environment instead of browser (JSDOM) ([#9874](https://github.com/facebook/jest/pull/9874))
6+
- `[jest-config, jest-runtime]` Support ESM for files other than `.js` and `.mjs` ([#10823](https://github.com/facebook/jest/pull/10823))
67
- `[jest-runner]` [**BREAKING**] set exit code to 1 if test logs after teardown ([#10728](https://github.com/facebook/jest/pull/10728))
78

89
### Fixes

docs/Configuration.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,23 @@ Default: `false`
353353

354354
Make calling deprecated APIs throw helpful error messages. Useful for easing the upgrade process.
355355

356+
### `extensionsToTreatAsEsm` [array\<string>]
357+
358+
Default: `[]`
359+
360+
Jest will run `.mjs` and `.js` files with nearest `package.json`'s `type` field set to `module` as ECMAScript Modules. If you have any other files that should run with native ESM, you need to specify their file extension here.
361+
362+
> Note: Jest's ESM support is still experimental, see [its docs for more details](ECMAScriptModules.md).
363+
364+
```json
365+
{
366+
...
367+
"jest": {
368+
"extensionsToTreatAsEsm": [".ts"]
369+
}
370+
}
371+
```
372+
356373
### `extraGlobals` [array\<string>]
357374

358375
Default: `undefined`

docs/ECMAScriptModules.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ Jest ships with _experimental_ support for ECMAScript Modules (ESM).
1212
With the warnings out of the way, this is how you activate ESM support in your tests.
1313

1414
1. Ensure you either disable [code transforms](./configuration#transform-objectstring-pathtotransformer--pathtotransformer-object) by passing `transform: {}` or otherwise configure your transformer to emit ESM rather than the default CommonJS (CJS).
15-
1. Execute `node` with `--experimental-vm-modules`, e.g. `node --experimental-vm-modules node_modules/.bin/jest` or `NODE_OPTIONS=--experimental-vm-modules npx jest` etc.. On Windows, you can use [`cross-env`](https://github.com/kentcdodds/cross-env) to be able to set environment variables
16-
1. Beyond that, we attempt to follow `node`'s logic for activating "ESM mode" (such as looking at `type` in `package.json` or `mjs` files), see [their docs](https://nodejs.org/api/esm.html#esm_enabling) for details
15+
1. Execute `node` with `--experimental-vm-modules`, e.g. `node --experimental-vm-modules node_modules/.bin/jest` or `NODE_OPTIONS=--experimental-vm-modules npx jest` etc.. On Windows, you can use [`cross-env`](https://github.com/kentcdodds/cross-env) to be able to set environment variables.
16+
1. Beyond that, we attempt to follow `node`'s logic for activating "ESM mode" (such as looking at `type` in `package.json` or `mjs` files), see [their docs](https://nodejs.org/api/esm.html#esm_enabling) for details.
17+
1. If you want to treat other file extensions (such as `ts`) as ESM, please use the [`extensionsToTreatAsEsm` option](Configuration.md#extensionstotreatasesm-arraystring).
1718

1819
## Differences between ESM and CommonJS
1920

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {resolve} from 'path';
9+
import {onNodeVersions} from '@jest/test-utils';
10+
import {json as runJest} from '../runJest';
11+
12+
const DIR = resolve(__dirname, '../native-esm-typescript');
13+
14+
// The versions where vm.Module exists and commonjs with "exports" is not broken
15+
onNodeVersions('^12.16.0 || >=13.7.0', () => {
16+
test('runs TS test with native ESM', () => {
17+
const {exitCode, json} = runJest(DIR, [], {
18+
nodeOptions: '--experimental-vm-modules',
19+
});
20+
21+
expect(exitCode).toBe(0);
22+
23+
expect(json.numTotalTests).toBe(1);
24+
expect(json.numPassedTests).toBe(1);
25+
});
26+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {double} from '../double';
9+
10+
test('test double', () => {
11+
expect(double(2)).toBe(4);
12+
});
13+
14+
test('test import.meta', () => {
15+
expect(typeof import.meta.url).toBe('string');
16+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
module.exports = {
9+
// importantly this does _not_ include `preset-env`
10+
presets: ['@babel/preset-typescript'],
11+
};

e2e/native-esm-typescript/double.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
export function double(num: number): number {
9+
return num * 2;
10+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "native-esm-typescript",
3+
"version": "1.0.0",
4+
"jest": {
5+
"extensionsToTreatAsEsm": [".ts"],
6+
"testEnvironment": "node"
7+
}
8+
}

packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,21 @@ const jestAdapter = async (
7878
});
7979

8080
for (const path of config.setupFilesAfterEnv) {
81-
// TODO: remove ? in Jest 26
82-
const esm = runtime.unstable_shouldLoadAsEsm?.(path);
81+
const esm = runtime.unstable_shouldLoadAsEsm(
82+
path,
83+
config.extensionsToTreatAsEsm,
84+
);
8385

8486
if (esm) {
8587
await runtime.unstable_importModule(path);
8688
} else {
8789
runtime.requireModule(path);
8890
}
8991
}
90-
91-
// TODO: remove ? in Jest 26
92-
const esm = runtime.unstable_shouldLoadAsEsm?.(testPath);
92+
const esm = runtime.unstable_shouldLoadAsEsm(
93+
testPath,
94+
config.extensionsToTreatAsEsm,
95+
);
9396

9497
if (esm) {
9598
await runtime.unstable_importModule(testPath);

packages/jest-config/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
"@types/glob": "^7.1.1",
4747
"@types/graceful-fs": "^4.1.3",
4848
"@types/micromatch": "^4.0.0",
49+
"jest-snapshot-serializer-raw": "^1.1.0",
50+
"strip-ansi": "^6.0.0",
4951
"ts-node": "^9.0.0",
5052
"typescript": "^4.0.3"
5153
},

packages/jest-config/src/Defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const defaultOptions: Config.DefaultOptions = {
2626
coverageReporters: ['json', 'text', 'lcov', 'clover'],
2727
errorOnDeprecated: false,
2828
expand: false,
29+
extensionsToTreatAsEsm: [],
2930
forceCoverageMatch: [],
3031
globals: {},
3132
haste: {

packages/jest-config/src/ValidConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const initialOptions: Config.InitialOptions = {
4444
} as const),
4545
errorOnDeprecated: false,
4646
expand: false,
47+
extensionsToTreatAsEsm: [],
4748
extraGlobals: [],
4849
filter: '<rootDir>/filter.js',
4950
forceCoverageMatch: ['**/*.t.js'],

packages/jest-config/src/__tests__/__snapshots__/normalize.test.js.snap

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,43 @@ exports[`displayName should throw an error when displayName is using invalid val
9191
<red></>"
9292
`;
9393
94+
exports[`extensionsToTreatAsEsm should enforce leading dots 1`] = `
95+
● Validation Error:
96+
97+
Option: extensionsToTreatAsEsm: ['ts'] includes a string that does not start with a period (.).
98+
Please change your configuration to extensionsToTreatAsEsm: ['.ts'].
99+
100+
Configuration Documentation:
101+
https://jestjs.io/docs/configuration.html
102+
`;
103+
104+
exports[`extensionsToTreatAsEsm throws on .cjs 1`] = `
105+
● Validation Error:
106+
107+
Option: extensionsToTreatAsEsm: ['.cjs'] includes '.cjs' which is always treated as CommonJS.
108+
109+
Configuration Documentation:
110+
https://jestjs.io/docs/configuration.html
111+
`;
112+
113+
exports[`extensionsToTreatAsEsm throws on .js 1`] = `
114+
● Validation Error:
115+
116+
Option: extensionsToTreatAsEsm: ['.js'] includes '.js' which is always inferred based on type in its nearest package.json.
117+
118+
Configuration Documentation:
119+
https://jestjs.io/docs/configuration.html
120+
`;
121+
122+
exports[`extensionsToTreatAsEsm throws on .mjs 1`] = `
123+
● Validation Error:
124+
125+
Option: extensionsToTreatAsEsm: ['.mjs'] includes '.mjs' which is always treated as an ECMAScript Module.
126+
127+
Configuration Documentation:
128+
https://jestjs.io/docs/configuration.html
129+
`;
130+
94131
exports[`preset throws when module was found but no "jest-preset.js" or "jest-preset.json" files 1`] = `
95132
"<red><bold><bold>● </><bold>Validation Error</>:</>
96133
<red></>

packages/jest-config/src/__tests__/normalize.test.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import crypto from 'crypto';
1010
import path from 'path';
11+
import {wrap} from 'jest-snapshot-serializer-raw';
12+
import stripAnsi from 'strip-ansi';
1113
import {escapeStrForRegex} from 'jest-regex-util';
1214
import Defaults from '../Defaults';
1315
import {DEFAULT_JS_PATTERN} from '../constants';
@@ -1713,3 +1715,36 @@ describe('testTimeout', () => {
17131715
).toThrowErrorMatchingSnapshot();
17141716
});
17151717
});
1718+
1719+
describe('extensionsToTreatAsEsm', () => {
1720+
function matchErrorSnapshot(callback) {
1721+
expect.assertions(1);
1722+
1723+
try {
1724+
callback();
1725+
} catch (error) {
1726+
expect(wrap(stripAnsi(error.message).trim())).toMatchSnapshot();
1727+
}
1728+
}
1729+
1730+
it('should pass valid config through', () => {
1731+
const {options} = normalize(
1732+
{extensionsToTreatAsEsm: ['.ts'], rootDir: '/root/'},
1733+
{},
1734+
);
1735+
1736+
expect(options.extensionsToTreatAsEsm).toEqual(['.ts']);
1737+
});
1738+
1739+
it('should enforce leading dots', () => {
1740+
matchErrorSnapshot(() =>
1741+
normalize({extensionsToTreatAsEsm: ['ts'], rootDir: '/root/'}, {}),
1742+
);
1743+
});
1744+
1745+
it.each(['.js', '.mjs', '.cjs'])('throws on %s', ext => {
1746+
matchErrorSnapshot(() =>
1747+
normalize({extensionsToTreatAsEsm: [ext], rootDir: '/root/'}, {}),
1748+
);
1749+
});
1750+
});

packages/jest-config/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ const groupOptions = (
178178
detectOpenHandles: options.detectOpenHandles,
179179
displayName: options.displayName,
180180
errorOnDeprecated: options.errorOnDeprecated,
181+
extensionsToTreatAsEsm: options.extensionsToTreatAsEsm,
181182
extraGlobals: options.extraGlobals,
182183
filter: options.filter,
183184
forceCoverageMatch: options.forceCoverageMatch,

packages/jest-config/src/normalize.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,63 @@ const showTestPathPatternError = (testPathPattern: string) => {
482482
);
483483
};
484484

485+
function validateExtensionsToTreatAsEsm(
486+
extensionsToTreatAsEsm: Config.InitialOptions['extensionsToTreatAsEsm'],
487+
) {
488+
if (!extensionsToTreatAsEsm || extensionsToTreatAsEsm.length === 0) {
489+
return;
490+
}
491+
492+
function printConfig(opts: Array<string>) {
493+
const string = opts.map(ext => `'${ext}'`).join(', ');
494+
495+
return `${chalk.bold(`extensionsToTreatAsEsm: [${string}]`)}`;
496+
}
497+
498+
const extensionWithoutDot = extensionsToTreatAsEsm.some(
499+
ext => !ext.startsWith('.'),
500+
);
501+
502+
if (extensionWithoutDot) {
503+
throw createConfigError(
504+
` Option: ${printConfig(
505+
extensionsToTreatAsEsm,
506+
)} includes a string that does not start with a period (${chalk.bold(
507+
'.',
508+
)}).
509+
Please change your configuration to ${printConfig(
510+
extensionsToTreatAsEsm.map(ext => (ext.startsWith('.') ? ext : `.${ext}`)),
511+
)}.`,
512+
);
513+
}
514+
515+
if (extensionsToTreatAsEsm.includes('.js')) {
516+
throw createConfigError(
517+
` Option: ${printConfig(extensionsToTreatAsEsm)} includes ${chalk.bold(
518+
"'.js'",
519+
)} which is always inferred based on ${chalk.bold(
520+
'type',
521+
)} in its nearest ${chalk.bold('package.json')}.`,
522+
);
523+
}
524+
525+
if (extensionsToTreatAsEsm.includes('.cjs')) {
526+
throw createConfigError(
527+
` Option: ${printConfig(extensionsToTreatAsEsm)} includes ${chalk.bold(
528+
"'.cjs'",
529+
)} which is always treated as CommonJS.`,
530+
);
531+
}
532+
533+
if (extensionsToTreatAsEsm.includes('.mjs')) {
534+
throw createConfigError(
535+
` Option: ${printConfig(extensionsToTreatAsEsm)} includes ${chalk.bold(
536+
"'.mjs'",
537+
)} which is always treated as an ECMAScript Module.`,
538+
);
539+
}
540+
}
541+
485542
export default function normalize(
486543
initialOptions: Config.InitialOptions,
487544
argv: Config.Argv,
@@ -577,6 +634,8 @@ export default function normalize(
577634
});
578635
}
579636

637+
validateExtensionsToTreatAsEsm(options.extensionsToTreatAsEsm);
638+
580639
const optionKeys = Object.keys(options) as Array<keyof Config.InitialOptions>;
581640

582641
optionKeys.reduce((newOptions, key: keyof Config.InitialOptions) => {
@@ -881,6 +940,7 @@ export default function normalize(
881940
case 'detectOpenHandles':
882941
case 'errorOnDeprecated':
883942
case 'expand':
943+
case 'extensionsToTreatAsEsm':
884944
case 'extraGlobals':
885945
case 'globals':
886946
case 'findRelatedTests':

packages/jest-jasmine2/src/index.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,10 @@ async function jasmine2(
149149
});
150150

151151
for (const path of config.setupFilesAfterEnv) {
152-
// TODO: remove ? in Jest 26
153-
const esm = runtime.unstable_shouldLoadAsEsm?.(path);
152+
const esm = runtime.unstable_shouldLoadAsEsm(
153+
path,
154+
config.extensionsToTreatAsEsm,
155+
);
154156

155157
if (esm) {
156158
await runtime.unstable_importModule(path);
@@ -163,9 +165,10 @@ async function jasmine2(
163165
const testNameRegex = new RegExp(globalConfig.testNamePattern, 'i');
164166
env.specFilter = (spec: Spec) => testNameRegex.test(spec.getFullName());
165167
}
166-
167-
// TODO: remove ? in Jest 26
168-
const esm = runtime.unstable_shouldLoadAsEsm?.(testPath);
168+
const esm = runtime.unstable_shouldLoadAsEsm(
169+
testPath,
170+
config.extensionsToTreatAsEsm,
171+
);
169172

170173
if (esm) {
171174
await runtime.unstable_importModule(testPath);

0 commit comments

Comments
 (0)