Skip to content

Commit

Permalink
feat: introduce helper package for implementing a custom JSDOM enviro…
Browse files Browse the repository at this point in the history
…ment (#14717)
  • Loading branch information
SimenB authored Nov 27, 2023
1 parent cecde21 commit 01a923b
Show file tree
Hide file tree
Showing 10 changed files with 295 additions and 180 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- `[@jest/core]` [**BREAKING**] Changed `--filter` to accept an object with shape `{ filtered: Array<string> }` 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))
Expand Down
8 changes: 8 additions & 0 deletions packages/jest-environment-jsdom-abstract/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**/__mocks__/**
**/__tests__/**
__typetests__
src
tsconfig.json
tsconfig.tsbuildinfo
api-extractor.json
.eslintcache
48 changes: 48 additions & 0 deletions packages/jest-environment-jsdom-abstract/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
191 changes: 191 additions & 0 deletions packages/jest-environment-jsdom-abstract/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<number>
{
dom: jsdom.JSDOM | null;
fakeTimers: LegacyFakeTimers<number> | null;
fakeTimersModern: ModernFakeTimers | null;
global: Win;
private errorEventListener: ((event: Event & {error: Error}) => void) | null;
moduleMocker: ModuleMocker | null;
customExportConditions = ['browser'];
private readonly _configuredExportConditions?: Array<string>;

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
: '<!DOCTYPE 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<typeof originalAddListener>
) {
if (args[0] === 'error') {
userErrorListenerCount++;
}
return originalAddListener.apply(this, args);
};
global.removeEventListener = function (
...args: Parameters<typeof originalRemoveListener>
) {
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<void> {}

async teardown(): Promise<void> {
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<string> {
return this._configuredExportConditions ?? this.customExportConditions;
}

getVmContext(): Context | null {
if (this.dom) {
return this.dom.getInternalVMContext();
}
return null;
}
}
16 changes: 16 additions & 0 deletions packages/jest-environment-jsdom-abstract/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"}
]
}
5 changes: 1 addition & 4 deletions packages/jest-environment-jsdom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading

0 comments on commit 01a923b

Please sign in to comment.