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: support files other than js to be ESM #10823

Merged
merged 4 commits into from Dec 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -4,6 +4,7 @@

- `[jest-config]` [**BREAKING**] Default to Node testing environment instead of browser (JSDOM) ([#9874](https://github.com/facebook/jest/pull/9874))
- `[jest-config]` [**BREAKING**] Use `jest-circus` as default test runner ([#10686](https://github.com/facebook/jest/pull/10686))
- `[jest-config, jest-runtime]` Support ESM for files other than `.js` and `.mjs` ([#10823](https://github.com/facebook/jest/pull/10823))
- `[jest-repl, jest-runner]` [**BREAKING**] Run transforms over environment ([#8751](https://github.com/facebook/jest/pull/8751))
- `[jest-runner]` [**BREAKING**] set exit code to 1 if test logs after teardown ([#10728](https://github.com/facebook/jest/pull/10728))
- `[jest-snapshot]` [**BREAKING**] Make prettier optional for inline snapshots - fall back to string replacement ([#7792](https://github.com/facebook/jest/pull/7792))
Expand Down
17 changes: 17 additions & 0 deletions docs/Configuration.md
Expand Up @@ -353,6 +353,23 @@ Default: `false`

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

### `extensionsToTreatAsEsm` [array\<string>]

Default: `[]`

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.

> Note: Jest's ESM support is still experimental, see [its docs for more details](ECMAScriptModules.md).

```json
{
...
"jest": {
"extensionsToTreatAsEsm": [".ts"]
}
}
```

### `extraGlobals` [array\<string>]

Default: `undefined`
Expand Down
5 changes: 3 additions & 2 deletions docs/ECMAScriptModules.md
Expand Up @@ -12,8 +12,9 @@ Jest ships with _experimental_ support for ECMAScript Modules (ESM).
With the warnings out of the way, this is how you activate ESM support in your tests.

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).
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
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
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.
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.
1. If you want to treat other file extensions (such as `ts`) as ESM, please use the [`extensionsToTreatAsEsm` option](Configuration.md#extensionstotreatasesm-arraystring).

## Differences between ESM and CommonJS

Expand Down
1 change: 1 addition & 0 deletions e2e/__tests__/__snapshots__/showConfig.test.ts.snap
Expand Up @@ -15,6 +15,7 @@ exports[`--showConfig outputs config info and exits 1`] = `
"detectLeaks": false,
"detectOpenHandles": false,
"errorOnDeprecated": false,
"extensionsToTreatAsEsm": [],
"extraGlobals": [],
"forceCoverageMatch": [],
"globals": {},
Expand Down
26 changes: 26 additions & 0 deletions e2e/__tests__/nativeEsmTypescript.test.ts
@@ -0,0 +1,26 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {resolve} from 'path';
import {onNodeVersions} from '@jest/test-utils';
import {json as runJest} from '../runJest';

const DIR = resolve(__dirname, '../native-esm-typescript');

// The versions where vm.Module exists and commonjs with "exports" is not broken
onNodeVersions('^12.16.0 || >=13.7.0', () => {
test('runs TS test with native ESM', () => {
const {exitCode, json} = runJest(DIR, [], {
nodeOptions: '--experimental-vm-modules',
});

expect(exitCode).toBe(0);

expect(json.numTotalTests).toBe(2);
expect(json.numPassedTests).toBe(2);
});
});
16 changes: 16 additions & 0 deletions e2e/native-esm-typescript/__tests__/double.test.ts
@@ -0,0 +1,16 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {double} from '../double';

test('test double', () => {
expect(double(2)).toBe(4);
});

test('test import.meta', () => {
expect(typeof import.meta.url).toBe('string');
});
11 changes: 11 additions & 0 deletions e2e/native-esm-typescript/babel.config.js
@@ -0,0 +1,11 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

module.exports = {
// importantly this does _not_ include `preset-env`
presets: ['@babel/preset-typescript'],
};
10 changes: 10 additions & 0 deletions e2e/native-esm-typescript/double.ts
@@ -0,0 +1,10 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

export function double(num: number): number {
return num * 2;
}
8 changes: 8 additions & 0 deletions e2e/native-esm-typescript/package.json
@@ -0,0 +1,8 @@
{
"name": "native-esm-typescript",
"version": "1.0.0",
"jest": {
"extensionsToTreatAsEsm": [".ts"],
"testEnvironment": "node"
}
}
Expand Up @@ -72,18 +72,15 @@ const jestAdapter = async (
});

for (const path of config.setupFilesAfterEnv) {
// TODO: remove ? in Jest 26
const esm = runtime.unstable_shouldLoadAsEsm?.(path);
const esm = runtime.unstable_shouldLoadAsEsm(path);

if (esm) {
await runtime.unstable_importModule(path);
} else {
runtime.requireModule(path);
}
}

// TODO: remove ? in Jest 26
const esm = runtime.unstable_shouldLoadAsEsm?.(testPath);
const esm = runtime.unstable_shouldLoadAsEsm(testPath);

if (esm) {
await runtime.unstable_importModule(testPath);
Expand Down
2 changes: 2 additions & 0 deletions packages/jest-config/package.json
Expand Up @@ -47,6 +47,8 @@
"@types/glob": "^7.1.1",
"@types/graceful-fs": "^4.1.3",
"@types/micromatch": "^4.0.0",
"jest-snapshot-serializer-raw": "^1.1.0",
"strip-ansi": "^6.0.0",
"ts-node": "^9.0.0",
"typescript": "^4.0.3"
},
Expand Down
1 change: 1 addition & 0 deletions packages/jest-config/src/Defaults.ts
Expand Up @@ -26,6 +26,7 @@ const defaultOptions: Config.DefaultOptions = {
coverageReporters: ['json', 'text', 'lcov', 'clover'],
errorOnDeprecated: false,
expand: false,
extensionsToTreatAsEsm: [],
forceCoverageMatch: [],
globals: {},
haste: {
Expand Down
1 change: 1 addition & 0 deletions packages/jest-config/src/ValidConfig.ts
Expand Up @@ -44,6 +44,7 @@ const initialOptions: Config.InitialOptions = {
} as const),
errorOnDeprecated: false,
expand: false,
extensionsToTreatAsEsm: [],
extraGlobals: [],
filter: '<rootDir>/filter.js',
forceCoverageMatch: ['**/*.t.js'],
Expand Down
Expand Up @@ -91,6 +91,43 @@ exports[`displayName should throw an error when displayName is using invalid val
<red></>"
`;

exports[`extensionsToTreatAsEsm should enforce leading dots 1`] = `
● Validation Error:

Option: extensionsToTreatAsEsm: ['ts'] includes a string that does not start with a period (.).
Please change your configuration to extensionsToTreatAsEsm: ['.ts'].

Configuration Documentation:
https://jestjs.io/docs/configuration.html
`;

exports[`extensionsToTreatAsEsm throws on .cjs 1`] = `
● Validation Error:

Option: extensionsToTreatAsEsm: ['.cjs'] includes '.cjs' which is always treated as CommonJS.

Configuration Documentation:
https://jestjs.io/docs/configuration.html
`;

exports[`extensionsToTreatAsEsm throws on .js 1`] = `
● Validation Error:

Option: extensionsToTreatAsEsm: ['.js'] includes '.js' which is always inferred based on type in its nearest package.json.

Configuration Documentation:
https://jestjs.io/docs/configuration.html
`;

exports[`extensionsToTreatAsEsm throws on .mjs 1`] = `
● Validation Error:

Option: extensionsToTreatAsEsm: ['.mjs'] includes '.mjs' which is always treated as an ECMAScript Module.

Configuration Documentation:
https://jestjs.io/docs/configuration.html
`;

exports[`preset throws when module was found but no "jest-preset.js" or "jest-preset.json" files 1`] = `
"<red><bold><bold>● </><bold>Validation Error</>:</>
<red></>
Expand Down
35 changes: 35 additions & 0 deletions packages/jest-config/src/__tests__/normalize.test.js
Expand Up @@ -8,6 +8,8 @@

import crypto from 'crypto';
import path from 'path';
import {wrap} from 'jest-snapshot-serializer-raw';
import stripAnsi from 'strip-ansi';
import {escapeStrForRegex} from 'jest-regex-util';
import Defaults from '../Defaults';
import {DEFAULT_JS_PATTERN} from '../constants';
Expand Down Expand Up @@ -1728,3 +1730,36 @@ describe('testTimeout', () => {
).toThrowErrorMatchingSnapshot();
});
});

describe('extensionsToTreatAsEsm', () => {
function matchErrorSnapshot(callback) {
expect.assertions(1);

try {
callback();
} catch (error) {
expect(wrap(stripAnsi(error.message).trim())).toMatchSnapshot();
}
}

it('should pass valid config through', () => {
const {options} = normalize(
{extensionsToTreatAsEsm: ['.ts'], rootDir: '/root/'},
{},
);

expect(options.extensionsToTreatAsEsm).toEqual(['.ts']);
});

it('should enforce leading dots', () => {
matchErrorSnapshot(() =>
normalize({extensionsToTreatAsEsm: ['ts'], rootDir: '/root/'}, {}),
);
});

it.each(['.js', '.mjs', '.cjs'])('throws on %s', ext => {
matchErrorSnapshot(() =>
normalize({extensionsToTreatAsEsm: [ext], rootDir: '/root/'}, {}),
);
});
});
1 change: 1 addition & 0 deletions packages/jest-config/src/index.ts
Expand Up @@ -178,6 +178,7 @@ const groupOptions = (
detectOpenHandles: options.detectOpenHandles,
displayName: options.displayName,
errorOnDeprecated: options.errorOnDeprecated,
extensionsToTreatAsEsm: options.extensionsToTreatAsEsm,
extraGlobals: options.extraGlobals,
filter: options.filter,
forceCoverageMatch: options.forceCoverageMatch,
Expand Down
60 changes: 60 additions & 0 deletions packages/jest-config/src/normalize.ts
Expand Up @@ -483,6 +483,63 @@ const showTestPathPatternError = (testPathPattern: string) => {
);
};

function validateExtensionsToTreatAsEsm(
extensionsToTreatAsEsm: Config.InitialOptions['extensionsToTreatAsEsm'],
) {
if (!extensionsToTreatAsEsm || extensionsToTreatAsEsm.length === 0) {
return;
}

function printConfig(opts: Array<string>) {
const string = opts.map(ext => `'${ext}'`).join(', ');

return chalk.bold(`extensionsToTreatAsEsm: [${string}]`);
}

const extensionWithoutDot = extensionsToTreatAsEsm.some(
ext => !ext.startsWith('.'),
);

if (extensionWithoutDot) {
throw createConfigError(
` Option: ${printConfig(
extensionsToTreatAsEsm,
)} includes a string that does not start with a period (${chalk.bold(
'.',
)}).
Please change your configuration to ${printConfig(
extensionsToTreatAsEsm.map(ext => (ext.startsWith('.') ? ext : `.${ext}`)),
)}.`,
);
}

if (extensionsToTreatAsEsm.includes('.js')) {
throw createConfigError(
` Option: ${printConfig(extensionsToTreatAsEsm)} includes ${chalk.bold(
"'.js'",
)} which is always inferred based on ${chalk.bold(
'type',
)} in its nearest ${chalk.bold('package.json')}.`,
);
}

if (extensionsToTreatAsEsm.includes('.cjs')) {
throw createConfigError(
` Option: ${printConfig(extensionsToTreatAsEsm)} includes ${chalk.bold(
"'.cjs'",
)} which is always treated as CommonJS.`,
);
}

if (extensionsToTreatAsEsm.includes('.mjs')) {
throw createConfigError(
` Option: ${printConfig(extensionsToTreatAsEsm)} includes ${chalk.bold(
"'.mjs'",
)} which is always treated as an ECMAScript Module.`,
);
}
}

export default function normalize(
initialOptions: Config.InitialOptions,
argv: Config.Argv,
Expand Down Expand Up @@ -584,6 +641,8 @@ export default function normalize(
});
}

validateExtensionsToTreatAsEsm(options.extensionsToTreatAsEsm);

const optionKeys = Object.keys(options) as Array<keyof Config.InitialOptions>;

optionKeys.reduce((newOptions, key: keyof Config.InitialOptions) => {
Expand Down Expand Up @@ -888,6 +947,7 @@ export default function normalize(
case 'detectOpenHandles':
case 'errorOnDeprecated':
case 'expand':
case 'extensionsToTreatAsEsm':
case 'extraGlobals':
case 'globals':
case 'findRelatedTests':
Expand Down
Expand Up @@ -12,6 +12,7 @@ exports[`prints the config object 1`] = `
"detectLeaks": false,
"detectOpenHandles": false,
"errorOnDeprecated": false,
"extensionsToTreatAsEsm": [],
"extraGlobals": [],
"forceCoverageMatch": [],
"globals": {},
Expand Down
7 changes: 2 additions & 5 deletions packages/jest-jasmine2/src/index.ts
Expand Up @@ -149,8 +149,7 @@ export default async function jasmine2(
});

for (const path of config.setupFilesAfterEnv) {
// TODO: remove ? in Jest 26
const esm = runtime.unstable_shouldLoadAsEsm?.(path);
const esm = runtime.unstable_shouldLoadAsEsm(path);

if (esm) {
await runtime.unstable_importModule(path);
Expand All @@ -163,9 +162,7 @@ export default async function jasmine2(
const testNameRegex = new RegExp(globalConfig.testNamePattern, 'i');
env.specFilter = (spec: Spec) => testNameRegex.test(spec.getFullName());
}

// TODO: remove ? in Jest 26
const esm = runtime.unstable_shouldLoadAsEsm?.(testPath);
const esm = runtime.unstable_shouldLoadAsEsm(testPath);

if (esm) {
await runtime.unstable_importModule(testPath);
Expand Down