diff --git a/CHANGELOG.md b/CHANGELOG.md index dd908cf69363..402c9a3d996a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - `[@jest/core]` [**BREAKING**] Changed `--filter` to accept an object with shape `{ filtered: Array }` to match [documentation](https://jestjs.io/docs/cli#--filterfile) ([#13319](https://github.com/jestjs/jest/pull/13319)) - `[@jest/core, @jest/test-sequencer]` [**BREAKING**] Exposes `globalConfig` & `contexts` to `TestSequencer` ([#14535](https://github.com/jestjs/jest/pull/14535), & [#14543](https://github.com/jestjs/jest/pull/14543)) - `[jest-environment-jsdom]` [**BREAKING**] Upgrade JSDOM to v22 ([#13825](https://github.com/jestjs/jest/pull/13825)) +- `[@jest/environment-jsdom-abstract]` Introduce new package which abstracts over the `jsdom` environment, allowing usage of custom versions of JSDOM ([#14717](https://github.com/jestjs/jest/pull/14717)) - `[@jest/fake-timers]` [**BREAKING**] Upgrade `@sinonjs/fake-timers` to v11 ([#14544](https://github.com/jestjs/jest/pull/14544)) - `[@jest/fake-timers]` Exposing new modern timers function `advanceTimersToFrame()` which advances all timers by the needed milliseconds to execute callbacks currently scheduled with `requestAnimationFrame` ([#14598](https://github.com/jestjs/jest/pull/14598)) - `[jest-runtime]` Exposing new modern timers function `jest.advanceTimersToFrame()` from `@jest/fake-timers` ([#14598](https://github.com/jestjs/jest/pull/14598)) diff --git a/packages/jest-environment-jsdom-abstract/.npmignore b/packages/jest-environment-jsdom-abstract/.npmignore new file mode 100644 index 000000000000..bb9fdb98bae7 --- /dev/null +++ b/packages/jest-environment-jsdom-abstract/.npmignore @@ -0,0 +1,8 @@ +**/__mocks__/** +**/__tests__/** +__typetests__ +src +tsconfig.json +tsconfig.tsbuildinfo +api-extractor.json +.eslintcache diff --git a/packages/jest-environment-jsdom-abstract/package.json b/packages/jest-environment-jsdom-abstract/package.json new file mode 100644 index 000000000000..bfaf739aceae --- /dev/null +++ b/packages/jest-environment-jsdom-abstract/package.json @@ -0,0 +1,48 @@ +{ + "name": "@jest/environment-jsdom-abstract", + "version": "30.0.0-alpha.2", + "repository": { + "type": "git", + "url": "https://github.com/jestjs/jest.git", + "directory": "packages/jest-environment-jsdom-abstract" + }, + "license": "MIT", + "main": "./build/index.js", + "types": "./build/index.d.ts", + "exports": { + ".": { + "types": "./build/index.d.ts", + "require": "./build/index.js", + "import": "./build/index.mjs", + "default": "./build/index.js" + }, + "./package.json": "./package.json" + }, + "dependencies": { + "@jest/environment": "workspace:*", + "@jest/fake-timers": "workspace:*", + "@jest/types": "workspace:*", + "@types/jsdom": "^21.1.1", + "@types/node": "*", + "jest-mock": "workspace:*", + "jest-util": "workspace:*" + }, + "devDependencies": { + "jsdom": "^22.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + }, + "engines": { + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/jest-environment-jsdom-abstract/src/index.ts b/packages/jest-environment-jsdom-abstract/src/index.ts new file mode 100644 index 000000000000..17164455c8d5 --- /dev/null +++ b/packages/jest-environment-jsdom-abstract/src/index.ts @@ -0,0 +1,191 @@ +/** + * 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 type {Context} from 'vm'; +import type * as jsdom from 'jsdom'; +import type { + EnvironmentContext, + JestEnvironment, + JestEnvironmentConfig, +} from '@jest/environment'; +import {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers'; +import type {Global} from '@jest/types'; +import {ModuleMocker} from 'jest-mock'; +import {installCommonGlobals} from 'jest-util'; + +// The `Window` interface does not have an `Error.stackTraceLimit` property, but +// `JSDOMEnvironment` assumes it is there. +type Win = Window & + Global.Global & { + Error: { + stackTraceLimit: number; + }; + }; + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +export default abstract class BaseJSDOMEnvironment + implements JestEnvironment +{ + dom: jsdom.JSDOM | null; + fakeTimers: LegacyFakeTimers | null; + fakeTimersModern: ModernFakeTimers | null; + global: Win; + private errorEventListener: ((event: Event & {error: Error}) => void) | null; + moduleMocker: ModuleMocker | null; + customExportConditions = ['browser']; + private readonly _configuredExportConditions?: Array; + + protected constructor( + config: JestEnvironmentConfig, + context: EnvironmentContext, + jsdomModule: typeof jsdom, + ) { + const {projectConfig} = config; + + const {JSDOM, ResourceLoader, VirtualConsole} = jsdomModule; + + const virtualConsole = new VirtualConsole(); + virtualConsole.sendTo(context.console, {omitJSDOMErrors: true}); + virtualConsole.on('jsdomError', error => { + context.console.error(error); + }); + + this.dom = new JSDOM( + typeof projectConfig.testEnvironmentOptions.html === 'string' + ? projectConfig.testEnvironmentOptions.html + : '', + { + pretendToBeVisual: true, + resources: + typeof projectConfig.testEnvironmentOptions.userAgent === 'string' + ? new ResourceLoader({ + userAgent: projectConfig.testEnvironmentOptions.userAgent, + }) + : undefined, + runScripts: 'dangerously', + url: 'http://localhost/', + virtualConsole, + ...projectConfig.testEnvironmentOptions, + }, + ); + const global = (this.global = this.dom.window as unknown as Win); + + if (global == null) { + throw new Error('JSDOM did not return a Window object'); + } + + // TODO: remove at some point - for "universal" code (code should use `globalThis`) + global.global = global; + + // Node's error-message stack size is limited at 10, but it's pretty useful + // to see more than that when a test fails. + this.global.Error.stackTraceLimit = 100; + installCommonGlobals(global, projectConfig.globals); + + // TODO: remove this ASAP, but it currently causes tests to run really slow + global.Buffer = Buffer; + + // Report uncaught errors. + this.errorEventListener = event => { + if (userErrorListenerCount === 0 && event.error != null) { + process.emit('uncaughtException', event.error); + } + }; + global.addEventListener('error', this.errorEventListener); + + // However, don't report them as uncaught if the user listens to 'error' event. + // In that case, we assume the might have custom error handling logic. + const originalAddListener = global.addEventListener.bind(global); + const originalRemoveListener = global.removeEventListener.bind(global); + let userErrorListenerCount = 0; + global.addEventListener = function ( + ...args: Parameters + ) { + if (args[0] === 'error') { + userErrorListenerCount++; + } + return originalAddListener.apply(this, args); + }; + global.removeEventListener = function ( + ...args: Parameters + ) { + if (args[0] === 'error') { + userErrorListenerCount--; + } + return originalRemoveListener.apply(this, args); + }; + + if ('customExportConditions' in projectConfig.testEnvironmentOptions) { + const {customExportConditions} = projectConfig.testEnvironmentOptions; + if ( + Array.isArray(customExportConditions) && + customExportConditions.every(isString) + ) { + this._configuredExportConditions = customExportConditions; + } else { + throw new Error( + 'Custom export conditions specified but they are not an array of strings', + ); + } + } + + this.moduleMocker = new ModuleMocker(global); + + this.fakeTimers = new LegacyFakeTimers({ + config: projectConfig, + global: global as unknown as typeof globalThis, + moduleMocker: this.moduleMocker, + timerConfig: { + idToRef: (id: number) => id, + refToId: (ref: number) => ref, + }, + }); + + this.fakeTimersModern = new ModernFakeTimers({ + config: projectConfig, + global: global as unknown as typeof globalThis, + }); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + async setup(): Promise {} + + async teardown(): Promise { + if (this.fakeTimers) { + this.fakeTimers.dispose(); + } + if (this.fakeTimersModern) { + this.fakeTimersModern.dispose(); + } + if (this.global != null) { + if (this.errorEventListener) { + this.global.removeEventListener('error', this.errorEventListener); + } + this.global.close(); + } + this.errorEventListener = null; + // @ts-expect-error: this.global not allowed to be `null` + this.global = null; + this.dom = null; + this.fakeTimers = null; + this.fakeTimersModern = null; + } + + exportConditions(): Array { + return this._configuredExportConditions ?? this.customExportConditions; + } + + getVmContext(): Context | null { + if (this.dom) { + return this.dom.getInternalVMContext(); + } + return null; + } +} diff --git a/packages/jest-environment-jsdom-abstract/tsconfig.json b/packages/jest-environment-jsdom-abstract/tsconfig.json new file mode 100644 index 000000000000..82e5be22db29 --- /dev/null +++ b/packages/jest-environment-jsdom-abstract/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "build", + "rootDir": "src" + }, + "include": ["./src/**/*"], + "exclude": ["./**/__mocks__/**/*", "./**/__tests__/**/*"], + "references": [ + {"path": "../jest-environment"}, + {"path": "../jest-fake-timers"}, + {"path": "../jest-mock"}, + {"path": "../jest-types"}, + {"path": "../jest-util"} + ] +} diff --git a/packages/jest-environment-jsdom/package.json b/packages/jest-environment-jsdom/package.json index d28d2bb5a5ce..69544c93cb22 100644 --- a/packages/jest-environment-jsdom/package.json +++ b/packages/jest-environment-jsdom/package.json @@ -20,12 +20,9 @@ }, "dependencies": { "@jest/environment": "workspace:*", - "@jest/fake-timers": "workspace:*", - "@jest/types": "workspace:*", + "@jest/environment-jsdom-abstract": "workspace:*", "@types/jsdom": "^21.1.1", "@types/node": "*", - "jest-mock": "workspace:*", - "jest-util": "workspace:*", "jsdom": "^22.0.0" }, "devDependencies": { diff --git a/packages/jest-environment-jsdom/src/index.ts b/packages/jest-environment-jsdom/src/index.ts index 994108dd2005..4849a3ca15c4 100644 --- a/packages/jest-environment-jsdom/src/index.ts +++ b/packages/jest-environment-jsdom/src/index.ts @@ -5,180 +5,16 @@ * LICENSE file in the root directory of this source tree. */ -import type {Context} from 'vm'; -import {JSDOM, ResourceLoader, VirtualConsole} from 'jsdom'; +import * as JSDOM from 'jsdom'; import type { EnvironmentContext, - JestEnvironment, JestEnvironmentConfig, } from '@jest/environment'; -import {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers'; -import type {Global} from '@jest/types'; -import {ModuleMocker} from 'jest-mock'; -import {installCommonGlobals} from 'jest-util'; - -// The `Window` interface does not have an `Error.stackTraceLimit` property, but -// `JSDOMEnvironment` assumes it is there. -type Win = Window & - Global.Global & { - Error: { - stackTraceLimit: number; - }; - }; - -function isString(value: unknown): value is string { - return typeof value === 'string'; -} - -export default class JSDOMEnvironment implements JestEnvironment { - dom: JSDOM | null; - fakeTimers: LegacyFakeTimers | null; - fakeTimersModern: ModernFakeTimers | null; - global: Win; - private errorEventListener: ((event: Event & {error: Error}) => void) | null; - moduleMocker: ModuleMocker | null; - customExportConditions = ['browser']; - private readonly _configuredExportConditions?: Array; +import BaseEnv from '@jest/environment-jsdom-abstract'; +export default class JSDOMEnvironment extends BaseEnv { constructor(config: JestEnvironmentConfig, context: EnvironmentContext) { - const {projectConfig} = config; - - const virtualConsole = new VirtualConsole(); - virtualConsole.sendTo(context.console, {omitJSDOMErrors: true}); - virtualConsole.on('jsdomError', error => { - context.console.error(error); - }); - - this.dom = new JSDOM( - typeof projectConfig.testEnvironmentOptions.html === 'string' - ? projectConfig.testEnvironmentOptions.html - : '', - { - pretendToBeVisual: true, - resources: - typeof projectConfig.testEnvironmentOptions.userAgent === 'string' - ? new ResourceLoader({ - userAgent: projectConfig.testEnvironmentOptions.userAgent, - }) - : undefined, - runScripts: 'dangerously', - url: 'http://localhost/', - virtualConsole, - ...projectConfig.testEnvironmentOptions, - }, - ); - const global = (this.global = this.dom.window as unknown as Win); - - if (global == null) { - throw new Error('JSDOM did not return a Window object'); - } - - // TODO: remove at some point - for "universal" code (code should use `globalThis`) - global.global = global; - - // Node's error-message stack size is limited at 10, but it's pretty useful - // to see more than that when a test fails. - this.global.Error.stackTraceLimit = 100; - installCommonGlobals(global, projectConfig.globals); - - // TODO: remove this ASAP, but it currently causes tests to run really slow - global.Buffer = Buffer; - - // Report uncaught errors. - this.errorEventListener = event => { - if (userErrorListenerCount === 0 && event.error != null) { - process.emit('uncaughtException', event.error); - } - }; - global.addEventListener('error', this.errorEventListener); - - // However, don't report them as uncaught if the user listens to 'error' event. - // In that case, we assume the might have custom error handling logic. - const originalAddListener = global.addEventListener.bind(global); - const originalRemoveListener = global.removeEventListener.bind(global); - let userErrorListenerCount = 0; - global.addEventListener = function ( - ...args: Parameters - ) { - if (args[0] === 'error') { - userErrorListenerCount++; - } - return originalAddListener.apply(this, args); - }; - global.removeEventListener = function ( - ...args: Parameters - ) { - if (args[0] === 'error') { - userErrorListenerCount--; - } - return originalRemoveListener.apply(this, args); - }; - - if ('customExportConditions' in projectConfig.testEnvironmentOptions) { - const {customExportConditions} = projectConfig.testEnvironmentOptions; - if ( - Array.isArray(customExportConditions) && - customExportConditions.every(isString) - ) { - this._configuredExportConditions = customExportConditions; - } else { - throw new Error( - 'Custom export conditions specified but they are not an array of strings', - ); - } - } - - this.moduleMocker = new ModuleMocker(global); - - this.fakeTimers = new LegacyFakeTimers({ - config: projectConfig, - global: global as unknown as typeof globalThis, - moduleMocker: this.moduleMocker, - timerConfig: { - idToRef: (id: number) => id, - refToId: (ref: number) => ref, - }, - }); - - this.fakeTimersModern = new ModernFakeTimers({ - config: projectConfig, - global: global as unknown as typeof globalThis, - }); - } - - // eslint-disable-next-line @typescript-eslint/no-empty-function - async setup(): Promise {} - - async teardown(): Promise { - if (this.fakeTimers) { - this.fakeTimers.dispose(); - } - if (this.fakeTimersModern) { - this.fakeTimersModern.dispose(); - } - if (this.global != null) { - if (this.errorEventListener) { - this.global.removeEventListener('error', this.errorEventListener); - } - this.global.close(); - } - this.errorEventListener = null; - // @ts-expect-error: this.global not allowed to be `null` - this.global = null; - this.dom = null; - this.fakeTimers = null; - this.fakeTimersModern = null; - } - - exportConditions(): Array { - return this._configuredExportConditions ?? this.customExportConditions; - } - - getVmContext(): Context | null { - if (this.dom) { - return this.dom.getInternalVMContext(); - } - return null; + super(config, context, JSDOM); } } diff --git a/packages/jest-environment-jsdom/tsconfig.json b/packages/jest-environment-jsdom/tsconfig.json index 82e5be22db29..00415cbfc572 100644 --- a/packages/jest-environment-jsdom/tsconfig.json +++ b/packages/jest-environment-jsdom/tsconfig.json @@ -8,9 +8,6 @@ "exclude": ["./**/__mocks__/**/*", "./**/__tests__/**/*"], "references": [ {"path": "../jest-environment"}, - {"path": "../jest-fake-timers"}, - {"path": "../jest-mock"}, - {"path": "../jest-types"}, - {"path": "../jest-util"} + {"path": "../jest-environment-jsdom-abstract"} ] } diff --git a/scripts/lintTs.mjs b/scripts/lintTs.mjs index 9169c16e4676..b79ef9514628 100644 --- a/scripts/lintTs.mjs +++ b/scripts/lintTs.mjs @@ -129,6 +129,9 @@ try { '@typescript-eslint/no-invalid-void-type': 'off', '@typescript-eslint/no-dynamic-delete': 'off', '@typescript-eslint/no-var-requires': 'off', + + // nah + '@typescript-eslint/require-await': 'off', }, }, }); diff --git a/yarn.lock b/yarn.lock index 68726a9a771c..a48c239fe5c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2899,6 +2899,27 @@ __metadata: languageName: unknown linkType: soft +"@jest/environment-jsdom-abstract@workspace:*, @jest/environment-jsdom-abstract@workspace:packages/jest-environment-jsdom-abstract": + version: 0.0.0-use.local + resolution: "@jest/environment-jsdom-abstract@workspace:packages/jest-environment-jsdom-abstract" + dependencies: + "@jest/environment": "workspace:*" + "@jest/fake-timers": "workspace:*" + "@jest/types": "workspace:*" + "@types/jsdom": ^21.1.1 + "@types/node": "*" + jest-mock: "workspace:*" + jest-util: "workspace:*" + jsdom: ^22.0.0 + peerDependencies: + canvas: ^2.5.0 + jsdom: "*" + peerDependenciesMeta: + canvas: + optional: true + languageName: unknown + linkType: soft + "@jest/environment@workspace:*, @jest/environment@workspace:packages/jest-environment": version: 0.0.0-use.local resolution: "@jest/environment@workspace:packages/jest-environment" @@ -13018,13 +13039,10 @@ __metadata: resolution: "jest-environment-jsdom@workspace:packages/jest-environment-jsdom" dependencies: "@jest/environment": "workspace:*" - "@jest/fake-timers": "workspace:*" + "@jest/environment-jsdom-abstract": "workspace:*" "@jest/test-utils": "workspace:*" - "@jest/types": "workspace:*" "@types/jsdom": ^21.1.1 "@types/node": "*" - jest-mock: "workspace:*" - jest-util: "workspace:*" jsdom: ^22.0.0 peerDependencies: canvas: ^2.5.0