packages/jest-environment-jsdom/src/index.ts (131 lines of code) (raw):

/** * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. * * 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 {JSDOM, ResourceLoader, VirtualConsole} 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; }; }; export default class JSDOMEnvironment implements JestEnvironment<number> { dom: JSDOM | null; fakeTimers: LegacyFakeTimers<number> | null; fakeTimersModern: ModernFakeTimers | null; global: Win; private errorEventListener: ((event: Event & {error: Error}) => void) | null; moduleMocker: ModuleMocker | null; 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 : '<!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.document .defaultView as unknown as Win); if (!global) { throw new Error('JSDOM did not return a Window object'); } // for "universal" code (code should use `globalThis`) global.global = global as any; // 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 as any, 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) { 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; const originalRemoveListener = global.removeEventListener; 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); }; this.moduleMocker = new ModuleMocker(global as any); const timerConfig = { idToRef: (id: number) => id, refToId: (ref: number) => ref, }; this.fakeTimers = new LegacyFakeTimers({ config: projectConfig, global: global as unknown as typeof globalThis, moduleMocker: this.moduleMocker, timerConfig, }); this.fakeTimersModern = new ModernFakeTimers({ config: projectConfig, global: global as unknown as typeof globalThis, }); } async setup(): Promise<void> {} async teardown(): Promise<void> { if (this.fakeTimers) { this.fakeTimers.dispose(); } if (this.fakeTimersModern) { this.fakeTimersModern.dispose(); } if (this.global) { if (this.errorEventListener) { this.global.removeEventListener('error', this.errorEventListener); } this.global.close(); // Dispose "document" to prevent "load" event from triggering. // Note that this.global.close() will trigger the CustomElement::disconnectedCallback // Do not reset the document before CustomElement disconnectedCallback function has finished running, // document should be accessible within disconnectedCallback. Object.defineProperty(this.global, 'document', {value: null}); } this.errorEventListener = null; // @ts-expect-error this.global = null; this.dom = null; this.fakeTimers = null; this.fakeTimersModern = null; } exportConditions(): Array<string> { return ['browser']; } getVmContext(): Context | null { if (this.dom) { return this.dom.getInternalVMContext(); } return null; } } export const TestEnvironment = JSDOMEnvironment;