Skip to content

Commit

Permalink
feat: support files other than js to be ESM
Browse files Browse the repository at this point in the history
  • Loading branch information
SimenB committed Nov 15, 2020
1 parent bc6dc7a commit 0a103ee
Show file tree
Hide file tree
Showing 28 changed files with 290 additions and 32 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -3,6 +3,7 @@
### Features

- `[jest-config]` [**BREAKING**] Default to Node testing environment instead of browser (JSDOM) ([#9874](https://github.com/facebook/jest/pull/9874))
- `[jest-config, jest-runtime]` Support ESM for files other than `.js` and `.mjs` ([#10823](https://github.com/facebook/jest/pull/10823))
- `[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))
- `[jest-repl, jest-runner]` [**BREAKING**] Run transforms over environment ([#8751](https://github.com/facebook/jest/pull/8751))
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"
}
}
13 changes: 8 additions & 5 deletions packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts
Expand Up @@ -72,18 +72,21 @@ 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,
config.extensionsToTreatAsEsm,
);

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,
config.extensionsToTreatAsEsm,
);

if (esm) {
await runtime.unstable_importModule(testPath);
Expand Down
2 changes: 2 additions & 0 deletions packages/jest-config/package.json
Expand Up @@ -46,6 +46,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 @@ -1713,3 +1715,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 @@ -482,6 +482,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 @@ -577,6 +634,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 @@ -881,6 +940,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
13 changes: 8 additions & 5 deletions packages/jest-jasmine2/src/index.ts
Expand Up @@ -149,8 +149,10 @@ 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,
config.extensionsToTreatAsEsm,
);

if (esm) {
await runtime.unstable_importModule(path);
Expand All @@ -163,9 +165,10 @@ 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,
config.extensionsToTreatAsEsm,
);

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

0 comments on commit 0a103ee

Please sign in to comment.