mirror of https://github.com/facebook/jest.git
Globals cleanup: avoid setting protection symbol when feature is off (#15684)
This commit is contained in:
parent
b00bd3c8ea
commit
33820adab2
|
@ -33,7 +33,7 @@ jobs:
|
|||
id: cpu-cores
|
||||
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2.0.0
|
||||
- name: run node-env tests
|
||||
run: yarn test-node-env
|
||||
run: yarn workspace jest-environment-node test
|
||||
- name: run tests
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2
|
||||
with:
|
||||
|
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
id: cpu-cores
|
||||
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2.0.0
|
||||
- name: run node-env tests
|
||||
run: yarn test-node-env
|
||||
run: yarn workspace jest-environment-node test
|
||||
- name: run tests
|
||||
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2
|
||||
with:
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
### Fixes
|
||||
|
||||
- `[jest-resolver]` Resolve builtin modules correctly ([#15683](https://github.com/jestjs/jest/pull/15683))
|
||||
- `[jest-environment-node, jest-util]` Avoid setting globals cleanup protection symbol when feature is off ([#15684](https://github.com/jestjs/jest/pull/15684))
|
||||
|
||||
### Chore & Maintenance
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ export default {
|
|||
},
|
||||
snapshotSerializers: [require.resolve('jest-serializer-ansi-escapes')],
|
||||
testEnvironmentOptions: {
|
||||
globalsCleanup: 'on',
|
||||
globalsCleanup: process.env.GLOBALS_CLEANUP ?? 'on',
|
||||
},
|
||||
testPathIgnorePatterns: [
|
||||
'/__arbitraries__/',
|
||||
|
|
|
@ -113,7 +113,6 @@
|
|||
"test-ts": "yarn jest --config jest.config.ts.mjs",
|
||||
"test-types": "yarn tstyche",
|
||||
"test-with-type-info": "yarn jest e2e/__tests__/jest.config.ts.test.ts",
|
||||
"test-node-env": "yarn jest packages/jest-environment-node/src/__tests__",
|
||||
"test": "yarn lint && yarn jest",
|
||||
"typecheck": "yarn typecheck:examples && yarn typecheck:tests",
|
||||
"typecheck:examples": "tsc -p examples/expect-extend && tsc -p examples/typescript",
|
||||
|
|
|
@ -31,6 +31,13 @@
|
|||
"@jest/test-utils": "workspace:*",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test:base": "echo GLOBALS_CLEANUP=$GLOBALS_CLEANUP && yarn --cwd='../.' jest --runInBand packages/jest-environment-node/src/__tests__",
|
||||
"test:globals-cleanup-off": "GLOBALS_CLEANUP=off yarn test:base",
|
||||
"test:globals-cleanup-soft": "GLOBALS_CLEANUP=soft yarn test:base",
|
||||
"test:globals-cleanup-on": "GLOBALS_CLEANUP=on yarn test:base",
|
||||
"test": "yarn test:globals-cleanup-off && yarn test:globals-cleanup-soft && yarn test:globals-cleanup-on"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
|
|
|
@ -9,7 +9,7 @@ import {AsyncLocalStorage, createHook} from 'async_hooks';
|
|||
import {clsx} from 'clsx';
|
||||
import {onNodeVersions} from '@jest/test-utils';
|
||||
|
||||
describe('NodeEnvironment 2', () => {
|
||||
describe('Globals Cleanup 1', () => {
|
||||
test('dispatch event', () => {
|
||||
new EventTarget().dispatchEvent(new Event('foo'));
|
||||
});
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {AsyncLocalStorage, createHook} from 'async_hooks';
|
||||
import {clsx} from 'clsx';
|
||||
import {onNodeVersions} from '@jest/test-utils';
|
||||
|
||||
describe('Globals Cleanup 2', () => {
|
||||
test('dispatch event', () => {
|
||||
new EventTarget().dispatchEvent(new Event('foo'));
|
||||
});
|
||||
|
||||
test('set modules on global', () => {
|
||||
(globalThis as any).async_hooks = require('async_hooks');
|
||||
(globalThis as any).AsyncLocalStorage =
|
||||
require('async_hooks').AsyncLocalStorage;
|
||||
(globalThis as any).createHook = require('async_hooks').createHook;
|
||||
(globalThis as any).clsx = require('clsx');
|
||||
expect(AsyncLocalStorage).toBeDefined();
|
||||
expect(clsx).toBeDefined();
|
||||
expect(createHook).toBeDefined();
|
||||
expect(createHook({})).toBeDefined();
|
||||
expect(clsx()).toBeDefined();
|
||||
});
|
||||
|
||||
onNodeVersions('>=19.8.0', () => {
|
||||
test('use static function from core module set on global', () => {
|
||||
expect(AsyncLocalStorage.snapshot).toBeDefined();
|
||||
expect(AsyncLocalStorage.snapshot()).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
function onlyIfGlobalsCleanup(
|
||||
globalsCleanup: string,
|
||||
testBody: () => void,
|
||||
): void {
|
||||
const describeFunc =
|
||||
process.env.GLOBALS_CLEANUP === globalsCleanup ? describe : describe.skip;
|
||||
describeFunc(`GLOBALS_CLEANUP=${globalsCleanup}`, testBody);
|
||||
}
|
||||
|
||||
describe('Globals Cleanup 3', () => {
|
||||
onlyIfGlobalsCleanup('off', () => {
|
||||
test('assign Object prototype descriptors to a new empty object', () => {
|
||||
const descriptors = Object.getOwnPropertyDescriptors(
|
||||
Object.getPrototypeOf({}),
|
||||
);
|
||||
Object.assign({}, descriptors);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,14 +6,8 @@
|
|||
*/
|
||||
|
||||
import type {EnvironmentContext} from '@jest/environment';
|
||||
import {
|
||||
makeGlobalConfig,
|
||||
makeProjectConfig,
|
||||
onNodeVersions,
|
||||
} from '@jest/test-utils';
|
||||
import {makeGlobalConfig, makeProjectConfig} from '@jest/test-utils';
|
||||
import NodeEnvironment from '../';
|
||||
import {AsyncLocalStorage, createHook} from 'async_hooks';
|
||||
import {clsx} from 'clsx';
|
||||
|
||||
const context: EnvironmentContext = {
|
||||
console,
|
||||
|
@ -93,28 +87,4 @@ describe('NodeEnvironment', () => {
|
|||
test('TextEncoder references the same global Uint8Array constructor', () => {
|
||||
expect(new TextEncoder().encode('abc')).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
test('dispatch event', () => {
|
||||
new EventTarget().dispatchEvent(new Event('foo'));
|
||||
});
|
||||
|
||||
test('set modules on global', () => {
|
||||
(globalThis as any).async_hooks = require('async_hooks');
|
||||
(globalThis as any).AsyncLocalStorage =
|
||||
require('async_hooks').AsyncLocalStorage;
|
||||
(globalThis as any).createHook = require('async_hooks').createHook;
|
||||
(globalThis as any).clsx = require('clsx');
|
||||
expect(AsyncLocalStorage).toBeDefined();
|
||||
expect(clsx).toBeDefined();
|
||||
expect(createHook).toBeDefined();
|
||||
expect(createHook({})).toBeDefined();
|
||||
expect(clsx()).toBeDefined();
|
||||
});
|
||||
|
||||
onNodeVersions('>=19.8.0', () => {
|
||||
test('use static function from core module set on global', () => {
|
||||
expect(AsyncLocalStorage.snapshot).toBeDefined();
|
||||
expect(AsyncLocalStorage.snapshot()).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,12 +12,13 @@ import type {
|
|||
JestEnvironmentConfig,
|
||||
} from '@jest/environment';
|
||||
import {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers';
|
||||
import type {Global} from '@jest/types';
|
||||
import type {Config, Global} from '@jest/types';
|
||||
import {ModuleMocker} from 'jest-mock';
|
||||
import {
|
||||
type DeletionMode,
|
||||
canDeleteProperties,
|
||||
deleteProperties,
|
||||
initializeGarbageCollectionUtils,
|
||||
installCommonGlobals,
|
||||
protectProperties,
|
||||
} from 'jest-util';
|
||||
|
@ -88,11 +89,14 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
|
|||
customExportConditions = ['node', 'node-addons'];
|
||||
private readonly _configuredExportConditions?: Array<string>;
|
||||
private _globalProxy: GlobalProxy;
|
||||
private _globalsCleanup: DeletionMode;
|
||||
|
||||
// while `context` is unused, it should always be passed
|
||||
constructor(config: JestEnvironmentConfig, _context: EnvironmentContext) {
|
||||
const {projectConfig} = config;
|
||||
|
||||
const globalsCleanupMode = readGlobalsCleanupConfig(projectConfig);
|
||||
initializeGarbageCollectionUtils(globalThis, globalsCleanupMode);
|
||||
|
||||
this._globalProxy = new GlobalProxy();
|
||||
this.context = createContext(this._globalProxy.proxy());
|
||||
const global = runInContext(
|
||||
|
@ -160,7 +164,7 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
|
|||
// same constructor is referenced by both.
|
||||
global.Uint8Array = Uint8Array;
|
||||
|
||||
installCommonGlobals(global, projectConfig.globals);
|
||||
installCommonGlobals(global, projectConfig.globals, globalsCleanupMode);
|
||||
|
||||
if ('asyncDispose' in Symbol && !('asyncDispose' in global.Symbol)) {
|
||||
const globalSymbol = global.Symbol as unknown as SymbolConstructor;
|
||||
|
@ -206,26 +210,6 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
|
|||
});
|
||||
|
||||
this._globalProxy.envSetupCompleted();
|
||||
this._globalsCleanup = (() => {
|
||||
const rawConfig = projectConfig.testEnvironmentOptions.globalsCleanup;
|
||||
const config = rawConfig?.toString()?.toLowerCase();
|
||||
switch (config) {
|
||||
case 'off':
|
||||
case 'on':
|
||||
case 'soft':
|
||||
return config;
|
||||
default: {
|
||||
if (config !== undefined) {
|
||||
logValidationWarning(
|
||||
'testEnvironmentOptions.globalsCleanup',
|
||||
`Unknown value given: ${rawConfig}`,
|
||||
'Available options are: [on, soft, off]',
|
||||
);
|
||||
}
|
||||
return 'soft';
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
|
@ -241,9 +225,7 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
|
|||
this.context = null;
|
||||
this.fakeTimers = null;
|
||||
this.fakeTimersModern = null;
|
||||
if (this._globalsCleanup !== 'off') {
|
||||
this._globalProxy.clear(this._globalsCleanup);
|
||||
}
|
||||
this._globalProxy.clear();
|
||||
}
|
||||
|
||||
exportConditions(): Array<string> {
|
||||
|
@ -293,10 +275,8 @@ class GlobalProxy implements ProxyHandler<typeof globalThis> {
|
|||
* Deletes any property that was set on the global object, except for:
|
||||
* 1. Properties that were set before {@link #envSetupCompleted} was invoked.
|
||||
* 2. Properties protected by {@link #protectProperties}.
|
||||
*
|
||||
* @param mode determines whether to soft or hard delete the properties.
|
||||
*/
|
||||
clear(mode: DeletionMode): void {
|
||||
clear(): void {
|
||||
for (const {value} of [
|
||||
...[...this.propertyToValue.entries()].map(([property, value]) => ({
|
||||
property,
|
||||
|
@ -304,7 +284,7 @@ class GlobalProxy implements ProxyHandler<typeof globalThis> {
|
|||
})),
|
||||
...this.leftovers,
|
||||
]) {
|
||||
deleteProperties(value, mode);
|
||||
deleteProperties(value);
|
||||
}
|
||||
this.propertyToValue.clear();
|
||||
this.leftovers = [];
|
||||
|
@ -365,3 +345,26 @@ class GlobalProxy implements ProxyHandler<typeof globalThis> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readGlobalsCleanupConfig(
|
||||
projectConfig: Config.ProjectConfig,
|
||||
): DeletionMode {
|
||||
const rawConfig = projectConfig.testEnvironmentOptions.globalsCleanup;
|
||||
const config = rawConfig?.toString()?.toLowerCase();
|
||||
switch (config) {
|
||||
case 'off':
|
||||
case 'on':
|
||||
case 'soft':
|
||||
return config;
|
||||
default: {
|
||||
if (config !== undefined) {
|
||||
logValidationWarning(
|
||||
'testEnvironmentOptions.globalsCleanup',
|
||||
`Unknown value given: ${rawConfig}`,
|
||||
'Available options are: [on, soft, off]',
|
||||
);
|
||||
}
|
||||
return 'soft';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,27 +4,69 @@
|
|||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
import chalk from 'chalk';
|
||||
|
||||
/**
|
||||
* The symbol that is set on the global object to store the deletion mode.
|
||||
*/
|
||||
const DELETION_MODE_SYMBOL = Symbol.for('$$jest-deletion-mode');
|
||||
|
||||
/**
|
||||
* The symbol that is set on objects to protect them from deletion.
|
||||
*
|
||||
* If the value is an empty array, then all properties will be protected.
|
||||
* If the value is an array of strings or symbols, then only those properties will be protected.
|
||||
*/
|
||||
const PROTECT_SYMBOL = Symbol.for('$$jest-protect-from-deletion');
|
||||
|
||||
/**
|
||||
* - <b>off</b>: deletion is completely turned off.
|
||||
* - <b>soft</b>: doesn't delete objects, but instead wraps their getter/setter with a deprecation warning.
|
||||
* - <b>on</b>: actually delete objects (using `delete`).
|
||||
*/
|
||||
export type DeletionMode = 'soft' | 'off' | 'on';
|
||||
|
||||
/**
|
||||
* Initializes the garbage collection utils with the given deletion mode.
|
||||
*
|
||||
* @param globalObject the global object on which to store the deletion mode.
|
||||
* @param deletionMode the deletion mode to use.
|
||||
*/
|
||||
export function initializeGarbageCollectionUtils(
|
||||
globalObject: typeof globalThis,
|
||||
deletionMode: DeletionMode,
|
||||
): void {
|
||||
const currentMode = Reflect.get(globalObject, DELETION_MODE_SYMBOL);
|
||||
if (currentMode && currentMode !== deletionMode) {
|
||||
console.warn(
|
||||
chalk.yellow(
|
||||
[
|
||||
'[jest-util] garbage collection deletion mode already initialized, ignoring new mode',
|
||||
` Current: '${currentMode}'`,
|
||||
` Given: '${deletionMode}'`,
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Reflect.set(globalObject, DELETION_MODE_SYMBOL, deletionMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all the properties from the given value (if it's an object),
|
||||
* unless the value was protected via {@link #protectProperties}.
|
||||
*
|
||||
* @param value the given value.
|
||||
* @param mode the deletion mode (see {@link #deleteProperty}).
|
||||
*/
|
||||
export function deleteProperties(value: unknown, mode: DeletionMode): void {
|
||||
if (canDeleteProperties(value)) {
|
||||
export function deleteProperties(value: unknown): void {
|
||||
if (getDeletionMode() !== 'off' && canDeleteProperties(value)) {
|
||||
const protectedKeys = getProtectedKeys(
|
||||
value,
|
||||
Reflect.get(value, PROTECT_SYMBOL),
|
||||
);
|
||||
for (const key of Reflect.ownKeys(value)) {
|
||||
if (!protectedKeys.includes(key) && key !== PROTECT_SYMBOL) {
|
||||
deleteProperty(value, key, mode);
|
||||
deleteProperty(value, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -46,8 +88,11 @@ export function protectProperties<T>(
|
|||
properties: Array<keyof T> = [],
|
||||
depth = 2,
|
||||
): boolean {
|
||||
// Reflect.get may cause deprecation warnings, so we disable them temporarily
|
||||
if (getDeletionMode() === 'off') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reflect.get may cause deprecation warnings, so we disable them temporarily
|
||||
const originalEmitWarning = process.emitWarning;
|
||||
|
||||
try {
|
||||
|
@ -105,17 +150,13 @@ export function canDeleteProperties(value: unknown): value is object {
|
|||
*
|
||||
* @returns whether the deletion was successful or not.
|
||||
*/
|
||||
function deleteProperty(
|
||||
obj: object,
|
||||
key: string | symbol,
|
||||
mode: DeletionMode,
|
||||
): boolean {
|
||||
function deleteProperty(obj: object, key: string | symbol): boolean {
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(obj, key);
|
||||
if (!descriptor?.configurable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode === 'on') {
|
||||
if (getDeletionMode() === 'on') {
|
||||
return Reflect.deleteProperty(obj, key);
|
||||
}
|
||||
|
||||
|
@ -137,6 +178,10 @@ function deleteProperty(
|
|||
});
|
||||
}
|
||||
|
||||
function getDeletionMode(): DeletionMode {
|
||||
return Reflect.get(globalThis, DELETION_MODE_SYMBOL) ?? 'off';
|
||||
}
|
||||
|
||||
const warningCache = new WeakSet<object>();
|
||||
|
||||
function emitAccessWarning(obj: object, key: string | symbol): void {
|
||||
|
@ -153,7 +198,7 @@ function emitAccessWarning(obj: object, key: string | symbol): void {
|
|||
detail: [
|
||||
'Jest deletes objects that were set on the global scope between test files to reduce memory leaks.',
|
||||
'Currently it only "soft" deletes them and emits this warning if those objects were accessed after their deletion.',
|
||||
'In future versions of Jest, this behavior will change to "hard", which will likely fail tests.',
|
||||
'In future versions of Jest, this behavior will change to "on", which will likely fail tests.',
|
||||
'You can change the behavior in your test configuration now to reduce memory usage.',
|
||||
]
|
||||
.map(s => ` ${s}`)
|
||||
|
|
|
@ -32,6 +32,7 @@ export {default as isNonNullable} from './isNonNullable';
|
|||
export {
|
||||
type DeletionMode,
|
||||
canDeleteProperties,
|
||||
initializeGarbageCollectionUtils,
|
||||
protectProperties,
|
||||
deleteProperties,
|
||||
} from './garbage-collection-utils';
|
||||
|
|
|
@ -9,12 +9,17 @@ import * as fs from 'graceful-fs';
|
|||
import type {Config} from '@jest/types';
|
||||
import createProcessObject from './createProcessObject';
|
||||
import deepCyclicCopy from './deepCyclicCopy';
|
||||
import {
|
||||
type DeletionMode,
|
||||
initializeGarbageCollectionUtils,
|
||||
} from './garbage-collection-utils';
|
||||
|
||||
const DTRACE = Object.keys(globalThis).filter(key => key.startsWith('DTRACE'));
|
||||
|
||||
export default function installCommonGlobals(
|
||||
globalObject: typeof globalThis,
|
||||
globals: Config.ConfigGlobals,
|
||||
garbageCollectionDeletionMode?: DeletionMode,
|
||||
): typeof globalThis & Config.ConfigGlobals {
|
||||
globalObject.process = createProcessObject();
|
||||
|
||||
|
@ -62,5 +67,12 @@ export default function installCommonGlobals(
|
|||
};
|
||||
}
|
||||
|
||||
if (garbageCollectionDeletionMode) {
|
||||
initializeGarbageCollectionUtils(
|
||||
globalObject,
|
||||
garbageCollectionDeletionMode,
|
||||
);
|
||||
}
|
||||
|
||||
return Object.assign(globalObject, deepCyclicCopy(globals));
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue