diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f61ede0f5b..1521ee378738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[jest-runtime]` Require stack when a module cannot be resolved ([#9681](https://github.com/facebook/jest/pull/9681)) - `[jest-config]` Support ESM config files with `.js` extension ([#9573](https://github.com/facebook/jest/pull/9573)). - `[jest-runtime]` Override `module.createRequire` to return a Jest-compatible `require` function ([#9469](https://github.com/facebook/jest/pull/9469)) - `[jest-haste-map]` [**BREAKING**] Remove `mapper` option ([#9581](https://github.com/facebook/jest/pull/9581)) @@ -11,6 +12,7 @@ ### Fixes +- `[jest-runtime]` Yarn PnP errors displayed to the user ([#9681](https://github.com/facebook/jest/pull/9681)) - `[expect]` Handle readonly properties correctly ([#9575](https://github.com/facebook/jest/pull/9575)) - `[jest-cli]` Set `coverageProvider` correctly when provided in config ([#9562](https://github.com/facebook/jest/pull/9562)) - `[jest-cli]` Allow specifying `.cjs` and `.mjs` config files by `--config` CLI option ([#9578](https://github.com/facebook/jest/pull/9578)) diff --git a/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap b/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap index 0ea10ceef1a3..d43ffe5dce4f 100644 --- a/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap +++ b/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap @@ -36,7 +36,7 @@ FAIL __tests__/index.js 12 | module.exports = () => 'test'; 13 | - at createNoMappedModuleFoundError (../../packages/jest-resolve/build/index.js:519:17) + at createNoMappedModuleFoundError (../../packages/jest-resolve/build/index.js:540:17) at Object.require (index.js:10:1) `; @@ -65,6 +65,6 @@ FAIL __tests__/index.js 12 | module.exports = () => 'test'; 13 | - at createNoMappedModuleFoundError (../../packages/jest-resolve/build/index.js:519:17) + at createNoMappedModuleFoundError (../../packages/jest-resolve/build/index.js:540:17) at Object.require (index.js:10:1) `; diff --git a/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap b/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap index 45ac2b1ca98d..a62a66e69f80 100644 --- a/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap +++ b/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap @@ -19,6 +19,11 @@ FAIL __tests__/test.js Cannot find module './some-json-file' from 'index.js' + Require stack: + index.js + __tests__/test.js + + However, Jest was able to find: './some-json-file.json' @@ -32,6 +37,6 @@ FAIL __tests__/test.js | ^ 9 | - at Resolver.resolveModule (../../packages/jest-resolve/build/index.js:276:11) + at Resolver.resolveModule (../../packages/jest-resolve/build/index.js:296:11) at Object.require (index.js:8:18) `; diff --git a/e2e/__tests__/pnp.test.ts b/e2e/__tests__/pnp.test.ts index 29d8f77362d0..fd60348a3820 100644 --- a/e2e/__tests__/pnp.test.ts +++ b/e2e/__tests__/pnp.test.ts @@ -24,5 +24,5 @@ it('sucessfully runs the tests inside `pnp/`', () => { nodeOptions: `--require ${DIR}/.pnp.js`, }); expect(json.success).toBe(true); - expect(json.numTotalTestSuites).toBe(1); + expect(json.numTotalTestSuites).toBe(2); }); diff --git a/e2e/pnp/__tests__/undeclared-dependency.test.js b/e2e/pnp/__tests__/undeclared-dependency.test.js new file mode 100644 index 000000000000..59372ad294a3 --- /dev/null +++ b/e2e/pnp/__tests__/undeclared-dependency.test.js @@ -0,0 +1,13 @@ +/** + * 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. + * + */ + +it('should surface pnp errors', () => { + expect(() => { + require('undeclared'); + }).toThrow(expect.objectContaining({code: 'UNDECLARED_DEPENDENCY'})); +}); diff --git a/e2e/pnp/package.json b/e2e/pnp/package.json index e25e02bd2111..78792eab2194 100644 --- a/e2e/pnp/package.json +++ b/e2e/pnp/package.json @@ -1,6 +1,7 @@ { "dependencies": { - "foo": "link:./lib" + "foo": "link:./lib", + "undeclared": "link:./undeclared-dependency" }, "installConfig": { "pnp": true diff --git a/e2e/pnp/undeclared-dependency/index.js b/e2e/pnp/undeclared-dependency/index.js new file mode 100644 index 000000000000..a00d5facdf0e --- /dev/null +++ b/e2e/pnp/undeclared-dependency/index.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. + * + */ + +const nope = require('unesitent_module__'); + +module.exports = () => nope; diff --git a/e2e/pnp/undeclared-dependency/package.json b/e2e/pnp/undeclared-dependency/package.json new file mode 100644 index 000000000000..1be1b18fe5a8 --- /dev/null +++ b/e2e/pnp/undeclared-dependency/package.json @@ -0,0 +1,3 @@ +{ + "version": "0.0.0" +} diff --git a/e2e/pnp/yarn.lock b/e2e/pnp/yarn.lock index 3f73f0689ff3..c9416ac9b6f6 100644 --- a/e2e/pnp/yarn.lock +++ b/e2e/pnp/yarn.lock @@ -5,3 +5,7 @@ "foo@link:./lib": version "0.0.0" uid "" + +"undeclared@link:./undeclared-dependency": + version "0.0.0" + uid "" diff --git a/e2e/resolve/Test7.js b/e2e/resolve/Test7.js new file mode 100644 index 000000000000..1df1689b6a56 --- /dev/null +++ b/e2e/resolve/Test7.js @@ -0,0 +1,12 @@ +/** + * 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. + * + */ + +require('./test1'); +const requiresUnexistingModule = require('./requiresUnexistingModule'); + +module.exports = {module: requiresUnexistingModule}; diff --git a/e2e/resolve/__tests__/nope.txt b/e2e/resolve/__tests__/nope.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/e2e/resolve/__tests__/resolve.test.js b/e2e/resolve/__tests__/resolve.test.js index 56f99f7ee945..bd993a99f8aa 100644 --- a/e2e/resolve/__tests__/resolve.test.js +++ b/e2e/resolve/__tests__/resolve.test.js @@ -6,6 +6,8 @@ */ 'use strict'; +const dedent = require('dedent'); + let platform; function testRequire(filename) { @@ -99,11 +101,27 @@ test('should require resolve haste mocks correctly', () => { expect(require('Test6').key).toBe('mock'); }); -test('should throw module not found error if the module cannot be found', () => { +test('should throw module not found error if the module has dependencies that cannot be found', () => { expect(() => require('Test7')).toThrow( expect.objectContaining({ code: 'MODULE_NOT_FOUND', - message: "Cannot find module 'Test7' from 'resolve.test.js'", + message: dedent` + Cannot find module 'nope' from 'requiresUnexistingModule.js' + + Require stack: + requiresUnexistingModule.js + Test7.js + __tests__/resolve.test.js\n + `, + }) + ); +}); + +test('should throw module not found error if the module cannot be found', () => { + expect(() => require('Test8')).toThrow( + expect.objectContaining({ + code: 'MODULE_NOT_FOUND', + message: "Cannot find module 'Test8' from 'resolve.test.js'", }) ); }); diff --git a/e2e/resolve/requiresUnexistingModule.js b/e2e/resolve/requiresUnexistingModule.js new file mode 100644 index 000000000000..9b964750bede --- /dev/null +++ b/e2e/resolve/requiresUnexistingModule.js @@ -0,0 +1,12 @@ +/** + * 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. + * + */ + +require('./test2'); +const unexistedModule = require('nope'); + +module.exports = {module: unexistedModule}; diff --git a/packages/jest-reporters/src/__tests__/notify_reporter.test.ts b/packages/jest-reporters/src/__tests__/notify_reporter.test.ts index 8c34af32d2ab..cbaaac084810 100644 --- a/packages/jest-reporters/src/__tests__/notify_reporter.test.ts +++ b/packages/jest-reporters/src/__tests__/notify_reporter.test.ts @@ -7,6 +7,7 @@ import type {AggregatedResult} from '@jest/test-result'; import type {Config} from '@jest/types'; +import Resolver from 'jest-resolve'; import NotifyReporter from '../notify_reporter'; import {makeGlobalConfig} from '../../../../TestUtils'; @@ -223,8 +224,9 @@ describe('node-notifier is an optional dependency', () => { test('without node-notifier uses mock function that throws an error', () => { jest.doMock('node-notifier', () => { - const error: any = new Error("Cannot find module 'node-notifier'"); - error.code = 'MODULE_NOT_FOUND'; + const error: any = new Resolver.ModuleNotFoundError( + "Cannot find module 'node-notifier'", + ); throw error; }); diff --git a/packages/jest-resolve/src/ModuleNotFoundError.ts b/packages/jest-resolve/src/ModuleNotFoundError.ts index 6ddfd2c9f372..064d07c4a9ae 100644 --- a/packages/jest-resolve/src/ModuleNotFoundError.ts +++ b/packages/jest-resolve/src/ModuleNotFoundError.ts @@ -5,6 +5,52 @@ * LICENSE file in the root directory of this source tree. */ +import * as path from 'path'; +import type {Config} from '@jest/types'; +import slash = require('slash'); + export default class ModuleNotFoundError extends Error { code = 'MODULE_NOT_FOUND'; + public hint?: string; + public requireStack?: Array; + public siblingWithSimilarExtensionFound?: boolean; + public moduleName?: string; + + private _originalMessage?: string; + + constructor(message: string, moduleName?: string) { + super(message); + this._originalMessage = message; + this.moduleName = moduleName; + } + + public buildMessage(rootDir: Config.Path): void { + if (!this._originalMessage) { + this._originalMessage = this.message || ''; + } + + let message = this._originalMessage; + + if (this?.requireStack?.length && this!.requireStack!.length > 1) { + message += ` + +Require stack: + ${(this.requireStack as Array) + .map(p => p.replace(`${rootDir}${path.sep}`, '')) + .map(slash) + .join('\n ')} +`; + } + + if (this.hint) { + message += this.hint; + } + + this.message = message; + } + + public static duckType(error: ModuleNotFoundError): ModuleNotFoundError { + error.buildMessage = ModuleNotFoundError.prototype.buildMessage; + return error; + } } diff --git a/packages/jest-resolve/src/index.ts b/packages/jest-resolve/src/index.ts index 22d9a83d8ebf..15856fb5aeb2 100644 --- a/packages/jest-resolve/src/index.ts +++ b/packages/jest-resolve/src/index.ts @@ -24,6 +24,7 @@ type FindNodeModuleConfig = { paths?: Array; resolver?: Config.Path | null; rootDir?: Config.Path; + throwIfNotFound?: boolean; }; type BooleanObject = Record; @@ -82,6 +83,21 @@ class Resolver { static ModuleNotFoundError = ModuleNotFoundError; + static tryCastModuleNotFoundError( + error: unknown, + ): ModuleNotFoundError | null { + if (error instanceof ModuleNotFoundError) { + return error as ModuleNotFoundError; + } + + const casted = error as ModuleNotFoundError; + if (casted.code === 'MODULE_NOT_FOUND') { + return ModuleNotFoundError.duckType(casted); + } + + return null; + } + static clearDefaultResolverCache(): void { clearDefaultResolverCache(); } @@ -105,7 +121,11 @@ class Resolver { paths: paths ? (nodePaths || []).concat(paths) : nodePaths, rootDir: options.rootDir, }); - } catch (e) {} + } catch (e) { + if (options.throwIfNotFound) { + throw e; + } + } return null; } @@ -155,7 +175,7 @@ class Resolver { const skipResolution = options && options.skipNodeResolution && !moduleName.includes(path.sep); - const resolveNodeModule = (name: Config.Path) => + const resolveNodeModule = (name: Config.Path, throwIfNotFound = false) => Resolver.findNodeModule(name, { basedir: dirname, browser: this._options.browser, @@ -164,10 +184,12 @@ class Resolver { paths, resolver: this._options.resolver, rootDir: this._options.rootDir, + throwIfNotFound, }); if (!skipResolution) { - module = resolveNodeModule(moduleName); + // @ts-ignore: the "pnp" version named isn't in DefinitelyTyped + module = resolveNodeModule(moduleName, Boolean(process.versions.pnp)); if (module) { this._moduleNameCache.set(key, module); @@ -215,6 +237,7 @@ class Resolver { throw new ModuleNotFoundError( `Cannot find module '${moduleName}' from '${relativePath || '.'}'`, + moduleName, ); } diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 854ec542ffcc..39a43443c26c 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -510,16 +510,23 @@ class Runtime { return this.requireModule(from, moduleName); } } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - const appendedMessage = findSiblingsWithFileExtension( - this._config.moduleFileExtensions, - from, - moduleName, - ); - - if (appendedMessage) { - e.message += appendedMessage; + const moduleNotFound = Resolver.tryCastModuleNotFoundError(e); + if (moduleNotFound) { + if ( + moduleNotFound.siblingWithSimilarExtensionFound === null || + moduleNotFound.siblingWithSimilarExtensionFound === undefined + ) { + moduleNotFound.hint = findSiblingsWithFileExtension( + this._config.moduleFileExtensions, + from, + moduleNotFound.moduleName || moduleName, + ); + moduleNotFound.siblingWithSimilarExtensionFound = Boolean( + moduleNotFound.hint, + ); } + moduleNotFound.buildMessage(this._config.rootDir); + throw moduleNotFound; } throw e; } @@ -841,28 +848,32 @@ class Runtime { return; } - compiledFunction.call( - localModule.exports, - localModule as NodeModule, // module object - localModule.exports, // module exports - localModule.require as typeof require, // require implementation - dirname, // __dirname - filename, // __filename - this._environment.global, // global object - this._createJestObjectFor( - filename, - localModule.require as LocalModuleRequire, - ), // jest object - ...this._config.extraGlobals.map(globalVariable => { - if (this._environment.global[globalVariable]) { - return this._environment.global[globalVariable]; - } + try { + compiledFunction.call( + localModule.exports, + localModule as NodeModule, // module object + localModule.exports, // module exports + localModule.require as typeof require, // require implementation + dirname, // __dirname + filename, // __filename + this._environment.global, // global object + this._createJestObjectFor( + filename, + localModule.require as LocalModuleRequire, + ), // jest object + ...this._config.extraGlobals.map(globalVariable => { + if (this._environment.global[globalVariable]) { + return this._environment.global[globalVariable]; + } - throw new Error( - `You have requested '${globalVariable}' as a global variable, but it was not present. Please check your config or your global environment.`, - ); - }), - ); + throw new Error( + `You have requested '${globalVariable}' as a global variable, but it was not present. Please check your config or your global environment.`, + ); + }), + ); + } catch (error) { + this.handleExecutionError(error, localModule); + } this._isCurrentlyExecutingManualMock = origCurrExecutingManualMock; this._currentlyExecutingModulePath = lastExecutingModulePath; @@ -1286,6 +1297,24 @@ class Runtime { ...this._config.extraGlobals, ]; } + + private handleExecutionError(e: Error, module: InitialModule): never { + const moduleNotFoundError = Resolver.tryCastModuleNotFoundError(e); + if (moduleNotFoundError) { + if (!moduleNotFoundError.requireStack) { + moduleNotFoundError.requireStack = [module.filename || module.id]; + + for (let cursor = module.parent; cursor; cursor = cursor.parent) { + moduleNotFoundError.requireStack.push(cursor.filename || cursor.id); + } + + moduleNotFoundError.buildMessage(this._config.rootDir); + } + throw moduleNotFoundError; + } + + throw e; + } } export = Runtime;