diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d46677941a5..06943f0ba891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Features +- `[jest-runtime, jest-jasmine2, jest-circus]` Experimental, limited ECMAScript Modules support ([#9772](https://github.com/facebook/jest/pull/9772)) + ### Fixes ### Chore & Maintenance diff --git a/e2e/__tests__/__snapshots__/nativeEsm.test.ts.snap b/e2e/__tests__/__snapshots__/nativeEsm.test.ts.snap new file mode 100644 index 000000000000..d3b49ee4f1c8 --- /dev/null +++ b/e2e/__tests__/__snapshots__/nativeEsm.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`on node ^12.16.0 || >=13.0.0 runs test with native ESM 1`] = ` +Test Suites: 1 passed, 1 total +Tests: 2 passed, 2 total +Snapshots: 0 total +Time: <> +Ran all test suites. +`; diff --git a/e2e/__tests__/nativeEsm.test.ts b/e2e/__tests__/nativeEsm.test.ts new file mode 100644 index 000000000000..a36c24c1e72d --- /dev/null +++ b/e2e/__tests__/nativeEsm.test.ts @@ -0,0 +1,36 @@ +/** + * 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 wrap from 'jest-snapshot-serializer-raw'; +import {onNodeVersions} from '@jest/test-utils'; +import runJest, {getConfig} from '../runJest'; +import {extractSummary} from '../Utils'; + +const DIR = resolve(__dirname, '../native-esm'); + +test('test config is without transform', () => { + const {configs} = getConfig(DIR); + + expect(configs).toHaveLength(1); + expect(configs[0].transform).toEqual([]); +}); + +// The versions vm.Module was introduced +onNodeVersions('^12.16.0 || >=13.0.0', () => { + test('runs test with native ESM', () => { + const {exitCode, stderr, stdout} = runJest(DIR, [], { + nodeOptions: '--experimental-vm-modules', + }); + + const {summary} = extractSummary(stderr); + + expect(wrap(summary)).toMatchSnapshot(); + expect(stdout).toBe(''); + expect(exitCode).toBe(0); + }); +}); diff --git a/e2e/native-esm/__tests__/native-esm.test.js b/e2e/native-esm/__tests__/native-esm.test.js new file mode 100644 index 000000000000..222c038fbc52 --- /dev/null +++ b/e2e/native-esm/__tests__/native-esm.test.js @@ -0,0 +1,23 @@ +/** + * 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 '../index'; + +test('should have correct import.meta', () => { + expect(typeof jest).toBe('undefined'); + expect(import.meta).toEqual({ + jest: expect.anything(), + url: expect.any(String), + }); + expect( + import.meta.url.endsWith('/e2e/native-esm/__tests__/native-esm.test.js') + ).toBe(true); +}); + +test('should double stuff', () => { + expect(double(1)).toBe(2); +}); diff --git a/e2e/native-esm/index.js b/e2e/native-esm/index.js new file mode 100644 index 000000000000..19bd49f5543e --- /dev/null +++ b/e2e/native-esm/index.js @@ -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) { + return num * 2; +} diff --git a/e2e/native-esm/package.json b/e2e/native-esm/package.json new file mode 100644 index 000000000000..5a90624bc1fe --- /dev/null +++ b/e2e/native-esm/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "jest": { + "testEnvironment": "node", + "transform": {} + } +} diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts index 239060e6dcab..c12e035eea66 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts @@ -77,9 +77,26 @@ const jestAdapter = async ( } }); - config.setupFilesAfterEnv.forEach(path => runtime.requireModule(path)); + await Promise.all( + config.setupFilesAfterEnv.map(async path => { + const esm = runtime.unstable_shouldLoadAsEsm(path); + + if (esm) { + await runtime.unstable_importModule(path); + } else { + runtime.requireModule(path); + } + }), + ); + + const esm = runtime.unstable_shouldLoadAsEsm(testPath); + + if (esm) { + await runtime.unstable_importModule(testPath); + } else { + runtime.requireModule(testPath); + } - runtime.requireModule(testPath); const results = await runAndTransformResultsToJestFormat({ config, globalConfig, diff --git a/packages/jest-jasmine2/src/index.ts b/packages/jest-jasmine2/src/index.ts index f8ab81713895..1a5d28c6edca 100644 --- a/packages/jest-jasmine2/src/index.ts +++ b/packages/jest-jasmine2/src/index.ts @@ -155,7 +155,17 @@ async function jasmine2( testPath, }); - config.setupFilesAfterEnv.forEach(path => runtime.requireModule(path)); + await Promise.all( + config.setupFilesAfterEnv.map(async path => { + const esm = runtime.unstable_shouldLoadAsEsm(path); + + if (esm) { + await runtime.unstable_importModule(path); + } else { + runtime.requireModule(path); + } + }), + ); if (globalConfig.enabledTestsMap) { env.specFilter = (spec: Spec) => { @@ -169,7 +179,14 @@ async function jasmine2( env.specFilter = (spec: Spec) => testNameRegex.test(spec.getFullName()); } - runtime.requireModule(testPath); + const esm = runtime.unstable_shouldLoadAsEsm(testPath); + + if (esm) { + await runtime.unstable_importModule(testPath); + } else { + runtime.requireModule(testPath); + } + await env.execute(); const results = await reporter.getResults(); diff --git a/packages/jest-runner/src/runTest.ts b/packages/jest-runner/src/runTest.ts index a617bc9d510c..fbaf7fded68b 100644 --- a/packages/jest-runner/src/runTest.ts +++ b/packages/jest-runner/src/runTest.ts @@ -67,6 +67,12 @@ function freezeConsole( }; } +function invariant(condition: unknown, message?: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + // Keeping the core of "runTest" as a separate function (as "runTestInternal") // is key to be able to detect memory leaks. Since all variables are local to // the function, when "runTestInternal" finishes its execution, they can all be @@ -163,7 +169,19 @@ async function runTestInternal( const start = Date.now(); - config.setupFiles.forEach(path => runtime!.requireModule(path)); + await Promise.all( + config.setupFiles.map(async path => { + invariant(runtime, 'TS is being difficult'); + + const esm = runtime.unstable_shouldLoadAsEsm(path); + + if (esm) { + await runtime.unstable_importModule(path); + } else { + runtime.requireModule(path); + } + }), + ); const sourcemapOptions: sourcemapSupport.Options = { environment: 'node', diff --git a/packages/jest-runtime/package.json b/packages/jest-runtime/package.json index bf389a1ef25f..e2eed1609e5c 100644 --- a/packages/jest-runtime/package.json +++ b/packages/jest-runtime/package.json @@ -38,6 +38,7 @@ "jest-snapshot": "^25.3.0", "jest-util": "^25.3.0", "jest-validate": "^25.3.0", + "read-pkg-up": "^7.0.1", "realpath-native": "^2.0.0", "slash": "^3.0.0", "strip-bom": "^4.0.0", diff --git a/packages/jest-runtime/src/__mocks__/createRuntime.js b/packages/jest-runtime/src/__mocks__/createRuntime.js index 1efc081eabfa..0e664a26ef4b 100644 --- a/packages/jest-runtime/src/__mocks__/createRuntime.js +++ b/packages/jest-runtime/src/__mocks__/createRuntime.js @@ -49,7 +49,17 @@ module.exports = async function createRuntime(filename, config) { Runtime.createResolver(config, hasteMap.moduleMap), ); - config.setupFiles.forEach(path => runtime.requireModule(path)); + await Promise.all( + config.setupFiles.map(async path => { + const esm = runtime.unstable_shouldLoadAsEsm(path); + + if (esm) { + await runtime.unstable_importModule(path); + } else { + runtime.requireModule(path); + } + }), + ); runtime.__mockRootPath = path.join(config.rootDir, 'root.js'); runtime.__mockSubdirPath = path.join( diff --git a/packages/jest-runtime/src/cli/index.ts b/packages/jest-runtime/src/cli/index.ts index e7c8e1dd3750..a2b22d9a6254 100644 --- a/packages/jest-runtime/src/cli/index.ts +++ b/packages/jest-runtime/src/cli/index.ts @@ -93,9 +93,24 @@ export async function run( const runtime = new Runtime(config, environment, hasteMap.resolver); - config.setupFiles.forEach(path => runtime.requireModule(path)); + await Promise.all( + config.setupFiles.map(async path => { + const esm = runtime.unstable_shouldLoadAsEsm(path); - runtime.requireModule(filePath); + if (esm) { + await runtime.unstable_importModule(path); + } else { + runtime.requireModule(path); + } + }), + ); + const esm = runtime.unstable_shouldLoadAsEsm(filePath); + + if (esm) { + await runtime.unstable_importModule(filePath); + } else { + runtime.requireModule(filePath); + } } catch (e) { console.error(chalk.red(e.stack || e)); process.on('exit', () => (process.exitCode = 1)); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 3566ac18cafa..efb41cb6665f 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -5,9 +5,16 @@ * LICENSE file in the root directory of this source tree. */ -import {URL, fileURLToPath} from 'url'; +import {URL, fileURLToPath, pathToFileURL} from 'url'; import * as path from 'path'; -import {Script, compileFunction} from 'vm'; +import { + Script, + // @ts-ignore: experimental, not added to the types + SourceTextModule, + // @ts-ignore: experimental, not added to the types + Module as VMModule, + compileFunction, +} from 'vm'; import * as nativeModule from 'module'; import type {Config} from '@jest/types'; import type { @@ -35,6 +42,7 @@ import * as fs from 'graceful-fs'; import {run as cliRun} from './cli'; import {options as cliOptions} from './cli/args'; import {findSiblingsWithFileExtension} from './helpers'; +import shouldLoadAsEsm, {runtimeSupportsVmModules} from './shouldLoadAsEsm'; import type {Context as JestContext} from './types'; import jestMock = require('jest-mock'); import HasteMap = require('jest-haste-map'); @@ -42,6 +50,10 @@ import Resolver = require('jest-resolve'); import Snapshot = require('jest-snapshot'); import stripBOM = require('strip-bom'); +interface JestImportMeta extends ImportMeta { + jest: Jest; +} + type HasteMapOptions = { console?: Console; maxWorkers: number; @@ -100,6 +112,12 @@ const EVAL_RESULT_VARIABLE = 'Object.'; type RunScriptEvalResult = {[EVAL_RESULT_VARIABLE]: ModuleWrapper}; +function invariant(condition: unknown, message?: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + /* eslint-disable-next-line no-redeclare */ class Runtime { private _cacheFS: CacheFS; @@ -119,6 +137,7 @@ class Runtime { private _moduleMocker: typeof jestMock; private _isolatedModuleRegistry: ModuleRegistry | null; private _moduleRegistry: ModuleRegistry; + private _esmoduleRegistry: Map; private _needsCoverageMapped: Set; private _resolver: Resolver; private _shouldAutoMock: boolean; @@ -162,6 +181,7 @@ class Runtime { this._isolatedModuleRegistry = null; this._isolatedMockRegistry = null; this._moduleRegistry = new Map(); + this._esmoduleRegistry = new Map(); this._needsCoverageMapped = new Set(); this._resolver = resolver; this._scriptTransformer = new ScriptTransformer(config); @@ -294,6 +314,77 @@ class Runtime { return cliOptions; } + // unstable as it should be replaced by https://github.com/nodejs/modules/issues/393, and we don't want people to use it + unstable_shouldLoadAsEsm = shouldLoadAsEsm; + + private async loadEsmModule( + modulePath: Config.Path, + query = '', + ): Promise { + const cacheKey = modulePath + query; + + if (!this._esmoduleRegistry.has(cacheKey)) { + const context = this._environment.getVmContext!(); + + invariant(context); + + const transformedFile = this.transformFile(modulePath, { + isInternalModule: false, + supportsDynamicImport: true, + supportsStaticESM: true, + }); + + const module = new SourceTextModule(transformedFile.code, { + context, + identifier: modulePath, + importModuleDynamically: ( + specifier: string, + referencingModule: VMModule, + ) => + this.loadEsmModule( + this._resolveModule(referencingModule.identifier, specifier), + ), + initializeImportMeta(meta: JestImportMeta) { + meta.url = pathToFileURL(modulePath).href; + // @ts-ignore TODO: fill this + meta.jest = {}; + }, + }); + + this._esmoduleRegistry.set(cacheKey, module); + } + + const module = this._esmoduleRegistry.get(cacheKey); + + invariant(module); + + return module; + } + + async unstable_importModule( + from: Config.Path, + moduleName?: string, + ): Promise { + invariant( + runtimeSupportsVmModules, + 'You need to run with a version of node that supports ES Modules in the VM API.', + ); + invariant( + typeof this._environment.getVmContext === 'function', + 'ES Modules are only supported if your test environment has the `getVmContext` function', + ); + + const modulePath = this._resolveModule(from, moduleName); + + const module = await this.loadEsmModule(modulePath); + await module.link((specifier: string, referencingModule: VMModule) => + this.loadEsmModule( + this._resolveModule(referencingModule.identifier, specifier), + ), + ); + await module.evaluate(); + } + requireModule( from: Config.Path, moduleName?: string, @@ -782,23 +873,8 @@ class Runtime { Object.defineProperty(localModule, 'require', { value: this._createRequireImplementation(localModule, options), }); - const transformedFile = this._scriptTransformer.transform( - filename, - this._getFullTransformationOptions(options), - this._cacheFS[filename], - ); - - // we only care about non-internal modules - if (!options || !options.isInternalModule) { - this._fileTransforms.set(filename, transformedFile); - } - if (transformedFile.sourceMapPath) { - this._sourceMapRegistry[filename] = transformedFile.sourceMapPath; - if (transformedFile.mapCoverage) { - this._needsCoverageMapped.add(filename); - } - } + const transformedFile = this.transformFile(filename, options); let compiledFunction: ModuleWrapper | null = null; @@ -890,6 +966,27 @@ class Runtime { this._currentlyExecutingModulePath = lastExecutingModulePath; } + private transformFile(filename: string, options?: InternalModuleOptions) { + const transformedFile = this._scriptTransformer.transform( + filename, + this._getFullTransformationOptions(options), + this._cacheFS[filename], + ); + + // we only care about non-internal modules + if (!options || !options.isInternalModule) { + this._fileTransforms.set(filename, transformedFile); + } + + if (transformedFile.sourceMapPath) { + this._sourceMapRegistry[filename] = transformedFile.sourceMapPath; + if (transformedFile.mapCoverage) { + this._needsCoverageMapped.add(filename); + } + } + return transformedFile; + } + private createScriptFromCode(scriptSource: string, filename: string) { try { return new Script(this.wrapCodeInModuleWrapper(scriptSource), { diff --git a/packages/jest-runtime/src/shouldLoadAsEsm.ts b/packages/jest-runtime/src/shouldLoadAsEsm.ts new file mode 100644 index 000000000000..d6ffe9c86215 --- /dev/null +++ b/packages/jest-runtime/src/shouldLoadAsEsm.ts @@ -0,0 +1,48 @@ +/** + * 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 {dirname, extname} from 'path'; +// @ts-ignore: experimental, not added to the types +import {SourceTextModule} from 'vm'; +import type {Config} from '@jest/types'; +import readPkgUp = require('read-pkg-up'); + +export const runtimeSupportsVmModules = typeof SourceTextModule === 'function'; + +// this is a bad version of what https://github.com/nodejs/modules/issues/393 would provide +export default function shouldLoadAsEsm(path: Config.Path): boolean { + if (!runtimeSupportsVmModules) { + return false; + } + + const extension = extname(path); + + if (extension === '.mjs') { + return true; + } + + if (extension === '.cjs') { + return false; + } + + // this isn't correct - we might wanna load any file as a module (using synthetic module) + // do we need an option to Jest so people can opt in to ESM for non-js? + if (extension !== '.js') { + return false; + } + + const cwd = dirname(path); + + // TODO: can we cache lookups somehow? + const pkg = readPkgUp.sync({cwd, normalize: false}); + + if (!pkg) { + return false; + } + + return pkg.packageJson.type === 'module'; +} diff --git a/yarn.lock b/yarn.lock index 349c5472d04a..ec6573c48d1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2425,6 +2425,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-13.9.3.tgz#6356df2647de9eac569f9a52eda3480fa9e70b4d" integrity sha512-01s+ac4qerwd6RHD+mVbOEsraDHSgUaefQlEdBbUolnQFjKwCr7luvAlEwW1RFojh67u0z4OUTjPn9LEl4zIkA== +"@types/normalize-package-data@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" + integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== + "@types/prettier@^1.19.0": version "1.19.1" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-1.19.1.tgz#33509849f8e679e4add158959fdb086440e9553f" @@ -9173,6 +9178,11 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lines-and-columns@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" + integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= + list-item@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/list-item/-/list-item-1.1.1.tgz#0c65d00e287cb663ccb3cb3849a77e89ec268a56" @@ -11197,6 +11207,16 @@ parse-json@^4.0.0: error-ex "^1.3.1" json-parse-better-errors "^1.0.1" +parse-json@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.0.0.tgz#73e5114c986d143efa3712d4ea24db9a4266f60f" + integrity sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + lines-and-columns "^1.1.6" + parse-node-version@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" @@ -12276,6 +12296,15 @@ read-pkg-up@^3.0.0: find-up "^2.0.0" read-pkg "^3.0.0" +read-pkg-up@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" + integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + dependencies: + find-up "^4.1.0" + read-pkg "^5.2.0" + type-fest "^0.8.1" + read-pkg@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" @@ -12303,6 +12332,16 @@ read-pkg@^3.0.0: normalize-package-data "^2.3.2" path-type "^3.0.0" +read-pkg@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + read@1, read@~1.0.1: version "1.0.7" resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4" @@ -14287,6 +14326,11 @@ type-fest@^0.3.0, type-fest@^0.3.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1" integrity sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ== +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== + type-fest@^0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.7.1.tgz#8dda65feaf03ed78f0a3f9678f1869147f7c5c48"