From 24938b2309abd83c41eb6f21217cf9a04090ff73 Mon Sep 17 00:00:00 2001 From: Eli Skeggs Date: Thu, 21 May 2020 12:07:15 -0700 Subject: [PATCH] feat: override Module#_compile Hook into the jest module loading mechanisms for the `Module#_compile` functionality provided by Node. Fixes #10069. --- CHANGELOG.md | 1 + .../__tests__/runtime_compile_module.test.js | 90 +++++++++++++++++++ packages/jest-runtime/src/index.ts | 71 ++++++++++++--- .../jest-transform/src/ScriptTransformer.ts | 3 +- packages/jest-transform/src/types.ts | 1 + 5 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 packages/jest-runtime/src/__tests__/runtime_compile_module.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e061d9be64c7..dcb4a2525106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - `[jest-config]` Support config files exporting (`async`) `function`s ([#10001](https://github.com/facebook/jest/pull/10001)) - `[jest-cli, jest-core]` Add `--selectProjects` CLI argument to filter test suites by project name ([#8612](https://github.com/facebook/jest/pull/8612)) +- `[jest-runtime]` Override `Module#_compile` to hook into jest's module loader ([#10072](https://github.com/facebook/jest/pull/10072)) ### Fixes diff --git a/packages/jest-runtime/src/__tests__/runtime_compile_module.test.js b/packages/jest-runtime/src/__tests__/runtime_compile_module.test.js new file mode 100644 index 000000000000..d7109c1473ce --- /dev/null +++ b/packages/jest-runtime/src/__tests__/runtime_compile_module.test.js @@ -0,0 +1,90 @@ +/** + * 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. + * + */ + +'use strict'; + +import {Module, builtinModules} from 'module'; +import path from 'path'; +import {pathToFileURL} from 'url'; +// eslint-disable-next-line import/default +import slash from 'slash'; +import * as fs from 'graceful-fs'; + +let createRuntime; + +describe('Runtime requireModule', () => { + beforeEach(() => { + createRuntime = require('createRuntime'); + }); + + it('overrides `Module#compile`', () => + createRuntime(__filename).then(runtime => { + const exports = runtime.requireModule(runtime.__mockRootPath, 'module'); + expect(exports.Module).not.toBe(Module); + + const mockFilename = name => + path.join(path.dirname(runtime.__mockRootPath), name); + + { + const pathRegularModule = mockFilename('RegularModule.js'); + const source = fs.readFileSync(pathRegularModule, 'utf-8'); + + const module = new exports.Module(); + module._compile(source, pathRegularModule); + expect(module).toMatchObject({ + children: expect.anything(), + exports: expect.anything(), + filename: null, + loaded: false, + parent: null, + paths: expect.anything(), + }); + // This is undefined in Node 10 and '' in Node 14 by default. + expect(module.id).toBeFalsy(); + expect(Object.keys(module.exports)).toEqual([ + 'filename', + 'getModuleStateValue', + 'isRealModule', + 'jest', + 'lazyRequire', + 'object', + 'parent', + 'paths', + 'setModuleStateValue', + 'module', + 'loaded', + 'isLoaded', + ]); + + expect(module.exports.getModuleStateValue()).toBe('default'); + + module.exports.lazyRequire(); + + // The dynamically compiled module should not be added to the registry, + // so no side effects should occur. + expect(module.exports.getModuleStateValue()).toBe('default'); + } + + { + const module = new exports.Module(); + module._compile('exports.value = 12;', mockFilename('dynamic.js')); + expect(module.exports).toEqual({value: 12}); + } + + { + const module = new exports.Module(); + let err; + try { + module._compile('{"value":12}', mockFilename('dynamic.json')); + } catch (e) { + err = e; + } + expect(err.name).toBe('SyntaxError'); + } + })); +}); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 47d66dbae539..345bbdae708d 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -70,6 +70,7 @@ type InternalModuleOptions = { isInternalModule: boolean; supportsDynamicImport: boolean; supportsStaticESM: boolean; + filenameOverride?: string; }; const defaultTransformOptions: InternalModuleOptions = { @@ -355,7 +356,7 @@ class Runtime { return core; } - const transformedCode = this.transformFile(modulePath, { + const transformedCode = this.transformFile(modulePath, undefined, { isInternalModule: false, supportsDynamicImport: true, supportsStaticESM: true, @@ -669,7 +670,13 @@ class Runtime { } else { // Only include the fromPath if a moduleName is given. Else treat as root. const fromPath = moduleName ? from : null; - this._execModule(localModule, options, moduleRegistry, fromPath); + this._execModule( + localModule, + undefined, + options, + moduleRegistry, + fromPath, + ); } localModule.loaded = true; } @@ -950,16 +957,17 @@ class Runtime { private _execModule( localModule: InitialModule, + moduleSource: string | undefined, options: InternalModuleOptions | undefined, moduleRegistry: ModuleRegistry, from: Config.Path | null, - ) { + ): any | undefined { // If the environment was disposed, prevent this module from being executed. if (!this._environment.global) { - return; + return undefined; } - const filename = localModule.filename; + const filename = options?.filenameOverride ?? localModule.filename; const lastExecutingModulePath = this._currentlyExecutingModulePath; this._currentlyExecutingModulePath = filename; const origCurrExecutingManualMock = this._isCurrentlyExecutingManualMock; @@ -981,7 +989,7 @@ class Runtime { value: this._createRequireImplementation(localModule, options), }); - const transformedCode = this.transformFile(filename, options); + const transformedCode = this.transformFile(filename, moduleSource, options); let compiledFunction: ModuleWrapper | null = null; @@ -1022,7 +1030,7 @@ class Runtime { 'You are trying to `import` a file after the Jest environment has been torn down.', ); process.exitCode = 1; - return; + return undefined; } const jestObject = this._createJestObjectFor(filename); @@ -1030,7 +1038,7 @@ class Runtime { this.jestObjectCaches.set(filename, jestObject); try { - compiledFunction.call( + return compiledFunction.call( localModule.exports, localModule as NodeModule, // module object localModule.exports, // module exports @@ -1055,13 +1063,16 @@ class Runtime { this._isCurrentlyExecutingManualMock = origCurrExecutingManualMock; this._currentlyExecutingModulePath = lastExecutingModulePath; + + return undefined; } private transformFile( filename: string, + moduleSource: string | undefined, options?: InternalModuleOptions, ): string { - const source = this.readFile(filename); + const source = moduleSource ?? this.readFile(filename); if (options?.isInternalModule) { return source; @@ -1155,8 +1166,28 @@ class Runtime { }); }; + const runtime = this; + // should we implement the class ourselves? - class Module extends nativeModule.Module {} + class Module extends nativeModule.Module { + _compile(content: string, filename: string) { + if (typeof content !== 'string') { + throw new TypeError('Module#_compile must receive string content'); + } + + if (typeof filename !== 'string') { + throw new TypeError('Module#_compile must receive string filename'); + } + + return runtime._execModule( + this, + content, + {...defaultTransformOptions, filenameOverride: filename}, + runtime._moduleRegistry, + null, + ); + } + } Object.entries(nativeModule.Module).forEach(([key, value]) => { // @ts-expect-error @@ -1307,16 +1338,28 @@ class Runtime { from: InitialModule, options?: InternalModuleOptions, ): NodeRequire { + const filenameOverride = options?.filenameOverride; + // TODO: somehow avoid having to type the arguments - they should come from `NodeRequire/LocalModuleRequire.resolve` const resolve = (moduleName: string, options: ResolveOptions) => - this._requireResolve(from.filename, moduleName, options); + this._requireResolve( + filenameOverride ?? from.filename, + moduleName, + options, + ); resolve.paths = (moduleName: string) => - this._requireResolvePaths(from.filename, moduleName); + this._requireResolvePaths(filenameOverride ?? from.filename, moduleName); const moduleRequire = (options?.isInternalModule ? (moduleName: string) => - this.requireInternalModule(from.filename, moduleName) - : this.requireModuleOrMock.bind(this, from.filename)) as NodeRequire; + this.requireInternalModule( + filenameOverride ?? from.filename, + moduleName, + ) + : this.requireModuleOrMock.bind( + this, + filenameOverride ?? from.filename, + )) as NodeRequire; moduleRequire.extensions = Object.create(null); moduleRequire.resolve = resolve; moduleRequire.cache = (() => { diff --git a/packages/jest-transform/src/ScriptTransformer.ts b/packages/jest-transform/src/ScriptTransformer.ts index fc4373c5fc48..ec409f46ff77 100644 --- a/packages/jest-transform/src/ScriptTransformer.ts +++ b/packages/jest-transform/src/ScriptTransformer.ts @@ -445,7 +445,8 @@ export default class ScriptTransformer { let scriptCacheKey = undefined; let instrument = false; - if (!options.isCoreModule) { + // Skip cache for core and dynamically loaded modules. + if (!options.isCoreModule && typeof options.filenameOverride !== 'string') { instrument = options.coverageProvider === 'babel' && shouldInstrument(filename, options, this._config); diff --git a/packages/jest-transform/src/types.ts b/packages/jest-transform/src/types.ts index 483c8e6cc9a4..0f58ee7ffcaa 100644 --- a/packages/jest-transform/src/types.ts +++ b/packages/jest-transform/src/types.ts @@ -25,6 +25,7 @@ export type Options = ShouldInstrumentOptions & isInternalModule: boolean; supportsDynamicImport: boolean; supportsStaticESM: boolean; + filenameOverride?: string; }>; // This is fixed in source-map@0.7.x, but we can't upgrade yet since it's async