Skip to content

Commit

Permalink
adds freezeCoreModules configuration option to mitigate memory leaks
Browse files Browse the repository at this point in the history
  • Loading branch information
lev-kazakov committed Nov 3, 2019
1 parent 220835c commit bde98f6
Show file tree
Hide file tree
Showing 15 changed files with 156 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,7 @@

### Features

- `[jest-config]` Add `freezeCoreModules` configuration option to mitigate memory leaks described in the following issues: [#6399](https://github.com/facebook/jest/issues/6399), [#6814](https://github.com/facebook/jest/issues/6814) ([#8331](https://github.com/facebook/jest/pull/8331))
- `[babel-plugin-jest-hoist]` Show codeframe on static hoisting issues ([#8865](https://github.com/facebook/jest/pull/8865))
- `[babel-plugin-jest-hoist]` Add `BigInt` to `WHITELISTED_IDENTIFIERS` ([#8382](https://github.com/facebook/jest/pull/8382))
- `[babel-preset-jest]` Add `@babel/plugin-syntax-bigint` ([#8382](https://github.com/facebook/jest/pull/8382))
Expand Down
3 changes: 3 additions & 0 deletions TestUtils.ts
Expand Up @@ -81,6 +81,8 @@ const DEFAULT_PROJECT_CONFIG: Config.ProjectConfig = {
extraGlobals: [],
filter: null,
forceCoverageMatch: [],
freezeCoreModules: false,
freezeCoreModulesWhitelist: ['crypto'],
globalSetup: null,
globalTeardown: null,
globals: {},
Expand Down Expand Up @@ -120,6 +122,7 @@ const DEFAULT_PROJECT_CONFIG: Config.ProjectConfig = {
transform: [],
transformIgnorePatterns: [],
unmockedModulePathPatterns: null,
verbose: false,
watchPathIgnorePatterns: [],
};

Expand Down
16 changes: 16 additions & 0 deletions docs/Configuration.md
Expand Up @@ -367,6 +367,22 @@ You can collect coverage from those files with setting `forceCoverageMatch`.
}
```

### `freezeCoreModules` [boolean]

Default: `false`

Prevents overriding node's core module methods so to prevent memory leaks.

Attempt to override such a property will fail silently.

Running with [`verbose`](#verbose-boolean) flag will enable informative print outs.

### `freezeCoreModulesWhitelist` [Array<string>]

Default: `['crypto']`

Array of node core modules which will not be frozen.

### `globals` [object]

Default: `{}`
Expand Down
5 changes: 5 additions & 0 deletions e2e/__tests__/__snapshots__/showConfig.test.ts.snap
Expand Up @@ -19,6 +19,10 @@ exports[`--showConfig outputs config info and exits 1`] = `
"errorOnDeprecated": false,
"filter": null,
"forceCoverageMatch": [],
"freezeCoreModules": false,
"freezeCoreModulesWhitelist": [
"crypto"
],
"globalSetup": null,
"globalTeardown": null,
"globals": {},
Expand Down Expand Up @@ -78,6 +82,7 @@ exports[`--showConfig outputs config info and exits 1`] = `
"transformIgnorePatterns": [
"/node_modules/"
],
"verbose": null,
"watchPathIgnorePatterns": []
}
],
Expand Down
Expand Up @@ -72,6 +72,14 @@ module.exports = {
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// Prevents overriding node's core module methods so to prevent memory leaks
// freezeCoreModules: false,
// Array of node core modules which will not be frozen
// freezeCoreModulesWhitelist: [
// \\"crypto\\"
// ],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: null,
Expand Down
2 changes: 2 additions & 0 deletions packages/jest-config/src/Defaults.ts
Expand Up @@ -31,6 +31,8 @@ const defaultOptions: Config.DefaultOptions = {
expand: false,
filter: null,
forceCoverageMatch: [],
freezeCoreModules: false,
freezeCoreModulesWhitelist: ['crypto'],
globalSetup: null,
globalTeardown: null,
globals: {},
Expand Down
4 changes: 4 additions & 0 deletions packages/jest-config/src/Descriptions.ts
Expand Up @@ -31,6 +31,10 @@ const descriptions: {[key in keyof Config.InitialOptions]: string} = {
'Make calling deprecated APIs throw helpful error messages',
forceCoverageMatch:
'Force coverage collection from ignored files using an array of glob patterns',
freezeCoreModules:
"Prevents overriding node's core module methods so to prevent memory leaks",
freezeCoreModulesWhitelist:
'Array of node core modules which will not be frozen',
globalSetup:
'A path to a module which exports an async function that is triggered once before all test suites',
globalTeardown:
Expand Down
2 changes: 2 additions & 0 deletions packages/jest-config/src/ValidConfig.ts
Expand Up @@ -48,6 +48,8 @@ const initialOptions: Config.InitialOptions = {
filter: '<rootDir>/filter.js',
forceCoverageMatch: ['**/*.t.js'],
forceExit: false,
freezeCoreModules: false,
freezeCoreModulesWhitelist: ['crypto'],
globalSetup: 'setup.js',
globalTeardown: 'teardown.js',
globals: {__DEV__: true},
Expand Down
3 changes: 3 additions & 0 deletions packages/jest-config/src/index.ts
Expand Up @@ -174,6 +174,8 @@ const groupOptions = (
extraGlobals: options.extraGlobals,
filter: options.filter,
forceCoverageMatch: options.forceCoverageMatch,
freezeCoreModules: options.freezeCoreModules,
freezeCoreModulesWhitelist: options.freezeCoreModulesWhitelist,
globalSetup: options.globalSetup,
globalTeardown: options.globalTeardown,
globals: options.globals,
Expand Down Expand Up @@ -211,6 +213,7 @@ const groupOptions = (
transform: options.transform,
transformIgnorePatterns: options.transformIgnorePatterns,
unmockedModulePathPatterns: options.unmockedModulePathPatterns,
verbose: options.verbose,
watchPathIgnorePatterns: options.watchPathIgnorePatterns,
}),
});
Expand Down
2 changes: 2 additions & 0 deletions packages/jest-config/src/normalize.ts
Expand Up @@ -852,6 +852,8 @@ export default function normalize(
case 'findRelatedTests':
case 'forceCoverageMatch':
case 'forceExit':
case 'freezeCoreModules':
case 'freezeCoreModulesWhitelist':
case 'lastCommit':
case 'listTests':
case 'logHeapUsage':
Expand Down
Expand Up @@ -16,6 +16,10 @@ exports[`prints the config object 1`] = `
"extraGlobals": [],
"filter": null,
"forceCoverageMatch": [],
"freezeCoreModules": false,
"freezeCoreModulesWhitelist": [
"crypto"
],
"globalSetup": null,
"globalTeardown": null,
"globals": {},
Expand Down Expand Up @@ -61,6 +65,7 @@ exports[`prints the config object 1`] = `
"transform": [],
"transformIgnorePatterns": [],
"unmockedModulePathPatterns": null,
"verbose": false,
"watchPathIgnorePatterns": []
},
"globalConfig": {
Expand Down
48 changes: 48 additions & 0 deletions packages/jest-runtime/src/__tests__/runtime_require_module.test.js
Expand Up @@ -184,6 +184,54 @@ describe('Runtime requireModule', () => {
}).not.toThrow();
}));

it('prevents overriding core module methods when `config.freezeCoreModules` is set', () =>
createRuntime(__filename, {freezeCoreModules: true}).then(runtime => {
const fs = runtime.requireModule(runtime.__mockRootPath, 'fs');
const originalFsClose = fs.close;

fs.close = () => {};

expect(fs.close).toBe(originalFsClose);
}));

it('allows mocking core module methods when `config.freezeCoreModules` is set', () =>
createRuntime(__filename, {freezeCoreModules: true}).then(runtime => {
const root = runtime.requireModule(runtime.__mockRootPath);
const fs = runtime.requireModule(runtime.__mockRootPath, 'fs');
let mockImplementationCalled = false;
const spy = root.jest.spyOn(fs, 'close').mockImplementation(() => {
mockImplementationCalled = true;
});

fs.close();

expect(mockImplementationCalled).toBe(true);
expect(spy).toHaveBeenCalled();
}));

it('allows overriding `crypto` core module methods by default', () =>
createRuntime(__filename, {freezeCoreModules: true}).then(runtime => {
const crypto = runtime.requireModule(runtime.__mockRootPath, 'crypto');
const cryptoRandomBytesOverride = () => {};

crypto.randomBytes = cryptoRandomBytesOverride;

expect(crypto.randomBytes).toBe(cryptoRandomBytesOverride);
}));

it('allows overriding core module methods when module is in `config.freezeCoreModulesWhitelist`', () =>
createRuntime(__filename, {
freezeCoreModules: true,
freezeCoreModulesWhitelist: ['fs'],
}).then(runtime => {
const fs = runtime.requireModule(runtime.__mockRootPath, 'fs');
const fsCloseOverride = () => {};

fs.close = fsCloseOverride;

expect(fs.close).toBe(fsCloseOverride);
}));

it('finds and loads JSON files without file extension', () =>
createRuntime(__filename).then(runtime => {
const exports = runtime.requireModule(
Expand Down
45 changes: 43 additions & 2 deletions packages/jest-runtime/src/index.ts
Expand Up @@ -18,7 +18,7 @@ import jestMock = require('jest-mock');
import HasteMap = require('jest-haste-map');
import {formatStackTrace, separateMessageFromStack} from 'jest-message-util';
import Resolver = require('jest-resolve');
import {createDirectory, deepCyclicCopy} from 'jest-util';
import {ErrorWithStack, createDirectory, deepCyclicCopy} from 'jest-util';
import {escapePathForRegex} from 'jest-regex-util';
import Snapshot = require('jest-snapshot');
import {
Expand Down Expand Up @@ -84,6 +84,7 @@ class Runtime {

private _cacheFS: CacheFS;
private _config: Config.ProjectConfig;
private _coreModulesProxyCache: {[moduleName: string]: any};
private _coverageOptions: ShouldInstrumentOptions;
private _currentlyExecutingModulePath: string;
private _environment: JestEnvironment;
Expand Down Expand Up @@ -125,6 +126,7 @@ class Runtime {
collectCoverageFrom: [],
collectCoverageOnlyFrom: null,
};
this._coreModulesProxyCache = Object.create(null);
this._currentlyExecutingModulePath = '';
this._environment = environment;
this._explicitShouldMock = Object.create(null);
Expand Down Expand Up @@ -776,7 +778,46 @@ class Runtime {
return this._environment.global.process;
}

return require(moduleName);
const module = require(moduleName);

if (
!this._config.freezeCoreModules ||
this._config.freezeCoreModulesWhitelist.includes(moduleName)
) {
return module;
}

if (this._coreModulesProxyCache[moduleName]) {
return this._coreModulesProxyCache[moduleName];
}

const set = (
target: object,
property: PropertyKey,
value: any,
receiver: any,
) => {
if (typeof value !== 'function' || value._isMockFunction) {
return Reflect.set(target, property, value, receiver);
}

if (this._config.verbose) {
console.warn(
new ErrorWithStack(
`Trying to override method '${property.toString()}' of a frozen core module '${moduleName}'`,
set,
),
);
}

return true;
};

const proxy = new Proxy(module, {set});

this._coreModulesProxyCache[moduleName] = proxy;

return proxy;
}

private _generateMock(from: Config.Path, moduleName: string) {
Expand Down
Expand Up @@ -17,6 +17,10 @@ Object {
"extraGlobals": Array [],
"filter": null,
"forceCoverageMatch": Array [],
"freezeCoreModules": false,
"freezeCoreModulesWhitelist": Array [
"crypto",
],
"globalSetup": null,
"globalTeardown": null,
"globals": Object {},
Expand Down Expand Up @@ -67,6 +71,7 @@ Object {
"/node_modules/",
],
"unmockedModulePathPatterns": null,
"verbose": false,
"watchPathIgnorePatterns": Array [],
},
"instrument": true,
Expand Down Expand Up @@ -218,7 +223,7 @@ exports[`ScriptTransformer uses multiple preprocessors 1`] = `
const TRANSFORMED = {
filename: '/fruits/banana.js',
script: 'module.exports = \\"banana\\";',
config: '{\\"automock\\":false,\\"browser\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extraGlobals\\":[],\\"filter\\":null,\\"forceCoverageMatch\\":[],\\"globalSetup\\":null,\\"globalTeardown\\":null,\\"globals\\":{},\\"haste\\":{\\"providesModuleNodeModules\\":[]},\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"resolver\\":null,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"snapshotResolver\\":null,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-jasmine2\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"^.+\\\\\\\\.js$\\",\\"test_preprocessor\\"],[\\"^.+\\\\\\\\.css$\\",\\"css-preprocessor\\"]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"unmockedModulePathPatterns\\":null,\\"watchPathIgnorePatterns\\":[]}',
config: '{\\"automock\\":false,\\"browser\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extraGlobals\\":[],\\"filter\\":null,\\"forceCoverageMatch\\":[],\\"freezeCoreModules\\":false,\\"freezeCoreModulesWhitelist\\":[\\"crypto\\"],\\"globalSetup\\":null,\\"globalTeardown\\":null,\\"globals\\":{},\\"haste\\":{\\"providesModuleNodeModules\\":[]},\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"resolver\\":null,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"snapshotResolver\\":null,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-jasmine2\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"^.+\\\\\\\\.js$\\",\\"test_preprocessor\\"],[\\"^.+\\\\\\\\.css$\\",\\"css-preprocessor\\"]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"unmockedModulePathPatterns\\":null,\\"verbose\\":false,\\"watchPathIgnorePatterns\\":[]}',
};
}});"
Expand All @@ -244,7 +249,7 @@ exports[`ScriptTransformer uses the supplied preprocessor 1`] = `
const TRANSFORMED = {
filename: '/fruits/banana.js',
script: 'module.exports = \\"banana\\";',
config: '{\\"automock\\":false,\\"browser\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extraGlobals\\":[],\\"filter\\":null,\\"forceCoverageMatch\\":[],\\"globalSetup\\":null,\\"globalTeardown\\":null,\\"globals\\":{},\\"haste\\":{\\"providesModuleNodeModules\\":[]},\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"resolver\\":null,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"snapshotResolver\\":null,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-jasmine2\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"^.+\\\\\\\\.js$\\",\\"test_preprocessor\\"]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"unmockedModulePathPatterns\\":null,\\"watchPathIgnorePatterns\\":[]}',
config: '{\\"automock\\":false,\\"browser\\":false,\\"cache\\":true,\\"cacheDirectory\\":\\"/cache/\\",\\"clearMocks\\":false,\\"coveragePathIgnorePatterns\\":[],\\"cwd\\":\\"/test_root_dir/\\",\\"detectLeaks\\":false,\\"detectOpenHandles\\":false,\\"errorOnDeprecated\\":false,\\"extraGlobals\\":[],\\"filter\\":null,\\"forceCoverageMatch\\":[],\\"freezeCoreModules\\":false,\\"freezeCoreModulesWhitelist\\":[\\"crypto\\"],\\"globalSetup\\":null,\\"globalTeardown\\":null,\\"globals\\":{},\\"haste\\":{\\"providesModuleNodeModules\\":[]},\\"moduleDirectories\\":[],\\"moduleFileExtensions\\":[\\"js\\"],\\"moduleLoader\\":\\"/test_module_loader_path\\",\\"moduleNameMapper\\":[],\\"modulePathIgnorePatterns\\":[],\\"modulePaths\\":[],\\"name\\":\\"test\\",\\"prettierPath\\":\\"prettier\\",\\"resetMocks\\":false,\\"resetModules\\":false,\\"resolver\\":null,\\"restoreMocks\\":false,\\"rootDir\\":\\"/\\",\\"roots\\":[],\\"runner\\":\\"jest-runner\\",\\"setupFiles\\":[],\\"setupFilesAfterEnv\\":[],\\"skipFilter\\":false,\\"skipNodeResolution\\":false,\\"snapshotResolver\\":null,\\"snapshotSerializers\\":[],\\"testEnvironment\\":\\"node\\",\\"testEnvironmentOptions\\":{},\\"testLocationInResults\\":false,\\"testMatch\\":[],\\"testPathIgnorePatterns\\":[],\\"testRegex\\":[\\"\\\\\\\\.test\\\\\\\\.js$\\"],\\"testRunner\\":\\"jest-jasmine2\\",\\"testURL\\":\\"http://localhost\\",\\"timers\\":\\"real\\",\\"transform\\":[[\\"^.+\\\\\\\\.js$\\",\\"test_preprocessor\\"]],\\"transformIgnorePatterns\\":[\\"/node_modules/\\"],\\"unmockedModulePathPatterns\\":null,\\"verbose\\":false,\\"watchPathIgnorePatterns\\":[]}',
};
}});"
Expand Down
7 changes: 7 additions & 0 deletions packages/jest-types/src/Config.ts
Expand Up @@ -52,6 +52,8 @@ export type DefaultOptions = {
expand: boolean;
filter: Path | null | undefined;
forceCoverageMatch: Array<Glob>;
freezeCoreModules: boolean;
freezeCoreModulesWhitelist: Array<string>;
globals: ConfigGlobals;
globalSetup: string | null | undefined;
globalTeardown: string | null | undefined;
Expand Down Expand Up @@ -150,6 +152,8 @@ export type InitialOptions = Partial<{
findRelatedTests: boolean;
forceCoverageMatch: Array<Glob>;
forceExit: boolean;
freezeCoreModules?: boolean;
freezeCoreModulesWhitelist?: Array<string>;
json: boolean;
globals: ConfigGlobals;
globalSetup: string | null | undefined;
Expand Down Expand Up @@ -386,6 +390,8 @@ export type ProjectConfig = {
extraGlobals: Array<keyof NodeJS.Global>;
filter: Path | null | undefined;
forceCoverageMatch: Array<Glob>;
freezeCoreModules: boolean;
freezeCoreModulesWhitelist: Array<string>;
globalSetup: string | null | undefined;
globalTeardown: string | null | undefined;
globals: ConfigGlobals;
Expand Down Expand Up @@ -424,6 +430,7 @@ export type ProjectConfig = {
transformIgnorePatterns: Array<Glob>;
watchPathIgnorePatterns: Array<string>;
unmockedModulePathPatterns: Array<string> | null | undefined;
verbose: boolean | null | undefined;
};

export type Argv = Arguments<
Expand Down

0 comments on commit bde98f6

Please sign in to comment.