Globals cleanup: avoid setting protection symbol when feature is off (#15684)

This commit is contained in:
Eyal Roth 2025-06-19 01:26:06 +03:00 committed by GitHub
parent b00bd3c8ea
commit 33820adab2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 178 additions and 78 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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

View File

@ -38,7 +38,7 @@ export default {
},
snapshotSerializers: [require.resolve('jest-serializer-ansi-escapes')],
testEnvironmentOptions: {
globalsCleanup: 'on',
globalsCleanup: process.env.GLOBALS_CLEANUP ?? 'on',
},
testPathIgnorePatterns: [
'/__arbitraries__/',

View File

@ -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",

View File

@ -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"
},

View File

@ -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'));
});

View File

@ -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();
});
});
});

View File

@ -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);
});
});
});

View File

@ -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();
});
});
});

View File

@ -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';
}
}
}

View File

@ -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}`)

View File

@ -32,6 +32,7 @@ export {default as isNonNullable} from './isNonNullable';
export {
type DeletionMode,
canDeleteProperties,
initializeGarbageCollectionUtils,
protectProperties,
deleteProperties,
} from './garbage-collection-utils';

View File

@ -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));
}