Skip to content

Commit

Permalink
feat: override Module#_compile
Browse files Browse the repository at this point in the history
Hook into the jest module loading mechanisms for the `Module#_compile`
functionality provided by Node. Fixes jestjs#10069.
  • Loading branch information
Eli Skeggs committed May 22, 2020
1 parent 81712ba commit 24938b2
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
90 changes: 90 additions & 0 deletions packages/jest-runtime/src/__tests__/runtime_compile_module.test.js
Original file line number Diff line number Diff line change
@@ -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');
}
}));
});
71 changes: 57 additions & 14 deletions packages/jest-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type InternalModuleOptions = {
isInternalModule: boolean;
supportsDynamicImport: boolean;
supportsStaticESM: boolean;
filenameOverride?: string;
};

const defaultTransformOptions: InternalModuleOptions = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -1022,15 +1030,15 @@ 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);

this.jestObjectCaches.set(filename, jestObject);

try {
compiledFunction.call(
return compiledFunction.call(
localModule.exports,
localModule as NodeModule, // module object
localModule.exports, // module exports
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = (() => {
Expand Down
3 changes: 2 additions & 1 deletion packages/jest-transform/src/ScriptTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/jest-transform/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 24938b2

Please sign in to comment.