|
| 1 | +/* eslint-disable */ |
| 2 | +// @ts-nocheck TODO: replace with `@jest/environment-jsdom-abstract` package when Jest 30 is released |
| 3 | + |
| 4 | +/** |
| 5 | + * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 6 | + * |
| 7 | + * This source code is licensed under the MIT license found in the |
| 8 | + * LICENSE file in the root directory of this source tree. |
| 9 | + */ |
| 10 | + |
| 11 | +import type { Context } from 'node:vm'; |
| 12 | + |
| 13 | +import type { EnvironmentContext, JestEnvironment, JestEnvironmentConfig } from '@jest/environment'; |
| 14 | +import { LegacyFakeTimers, ModernFakeTimers } from '@jest/fake-timers'; |
| 15 | +import type { Global } from '@jest/types'; |
| 16 | +import { ModuleMocker } from 'jest-mock'; |
| 17 | +import { installCommonGlobals } from 'jest-util'; |
| 18 | +import type * as jsdom from 'jsdom'; |
| 19 | + |
| 20 | +// The `Window` interface does not have an `Error.stackTraceLimit` property, but |
| 21 | +// `JSDOMEnvironment` assumes it is there. |
| 22 | +type Win = Window & |
| 23 | + Global.Global & { |
| 24 | + Error: { |
| 25 | + stackTraceLimit: number; |
| 26 | + }; |
| 27 | + }; |
| 28 | + |
| 29 | +function isString(value: unknown): value is string { |
| 30 | + return typeof value === 'string'; |
| 31 | +} |
| 32 | + |
| 33 | +export default abstract class BaseJSDOMEnvironment implements JestEnvironment<number> { |
| 34 | + dom: jsdom.JSDOM | null; |
| 35 | + fakeTimers: LegacyFakeTimers<number> | null; |
| 36 | + fakeTimersModern: ModernFakeTimers | null; |
| 37 | + global: Win; |
| 38 | + private errorEventListener: ((event: Event & { error: Error }) => void) | null; |
| 39 | + moduleMocker: ModuleMocker | null; |
| 40 | + customExportConditions = ['browser']; |
| 41 | + private readonly _configuredExportConditions?: Array<string>; |
| 42 | + |
| 43 | + protected constructor(config: JestEnvironmentConfig, context: EnvironmentContext, jsdomModule: typeof jsdom) { |
| 44 | + const { projectConfig } = config; |
| 45 | + |
| 46 | + const { JSDOM, ResourceLoader, VirtualConsole } = jsdomModule; |
| 47 | + |
| 48 | + const virtualConsole = new VirtualConsole(); |
| 49 | + virtualConsole.sendTo(context.console, { omitJSDOMErrors: true }); |
| 50 | + virtualConsole.on('jsdomError', (error) => { |
| 51 | + context.console.error(error); |
| 52 | + }); |
| 53 | + |
| 54 | + this.dom = new JSDOM( |
| 55 | + typeof projectConfig.testEnvironmentOptions.html === 'string' |
| 56 | + ? projectConfig.testEnvironmentOptions.html |
| 57 | + : '<!DOCTYPE html>', |
| 58 | + { |
| 59 | + pretendToBeVisual: true, |
| 60 | + resources: |
| 61 | + typeof projectConfig.testEnvironmentOptions.userAgent === 'string' |
| 62 | + ? new ResourceLoader({ |
| 63 | + userAgent: projectConfig.testEnvironmentOptions.userAgent, |
| 64 | + }) |
| 65 | + : undefined, |
| 66 | + runScripts: 'dangerously', |
| 67 | + url: 'http://localhost/', |
| 68 | + virtualConsole, |
| 69 | + ...projectConfig.testEnvironmentOptions, |
| 70 | + }, |
| 71 | + ); |
| 72 | + const global = (this.global = this.dom.window as unknown as Win); |
| 73 | + |
| 74 | + if (global == null) { |
| 75 | + throw new Error('JSDOM did not return a Window object'); |
| 76 | + } |
| 77 | + |
| 78 | + global.global = global; |
| 79 | + |
| 80 | + // Node's error-message stack size is limited at 10, but it's pretty useful |
| 81 | + // to see more than that when a test fails. |
| 82 | + this.global.Error.stackTraceLimit = 100; |
| 83 | + installCommonGlobals(global, projectConfig.globals); |
| 84 | + |
| 85 | + // Report uncaught errors. |
| 86 | + this.errorEventListener = (event) => { |
| 87 | + if (userErrorListenerCount === 0 && event.error != null) { |
| 88 | + process.emit('uncaughtException', event.error); |
| 89 | + } |
| 90 | + }; |
| 91 | + global.addEventListener('error', this.errorEventListener); |
| 92 | + |
| 93 | + // However, don't report them as uncaught if the user listens to 'error' event. |
| 94 | + // In that case, we assume the might have custom error handling logic. |
| 95 | + const originalAddListener = global.addEventListener.bind(global); |
| 96 | + const originalRemoveListener = global.removeEventListener.bind(global); |
| 97 | + let userErrorListenerCount = 0; |
| 98 | + global.addEventListener = function (...args: Parameters<typeof originalAddListener>) { |
| 99 | + if (args[0] === 'error') { |
| 100 | + userErrorListenerCount++; |
| 101 | + } |
| 102 | + |
| 103 | + return originalAddListener.apply(this, args); |
| 104 | + }; |
| 105 | + global.removeEventListener = function (...args: Parameters<typeof originalRemoveListener>) { |
| 106 | + if (args[0] === 'error') { |
| 107 | + userErrorListenerCount--; |
| 108 | + } |
| 109 | + |
| 110 | + return originalRemoveListener.apply(this, args); |
| 111 | + }; |
| 112 | + |
| 113 | + if ('customExportConditions' in projectConfig.testEnvironmentOptions) { |
| 114 | + const { customExportConditions } = projectConfig.testEnvironmentOptions; |
| 115 | + if (Array.isArray(customExportConditions) && customExportConditions.every(isString)) { |
| 116 | + this._configuredExportConditions = customExportConditions; |
| 117 | + } else { |
| 118 | + throw new Error('Custom export conditions specified but they are not an array of strings'); |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + this.moduleMocker = new ModuleMocker(global); |
| 123 | + |
| 124 | + this.fakeTimers = new LegacyFakeTimers({ |
| 125 | + config: projectConfig, |
| 126 | + global: global as unknown as typeof globalThis, |
| 127 | + moduleMocker: this.moduleMocker, |
| 128 | + timerConfig: { |
| 129 | + idToRef: (id: number) => id, |
| 130 | + refToId: (ref: number) => ref, |
| 131 | + }, |
| 132 | + }); |
| 133 | + |
| 134 | + this.fakeTimersModern = new ModernFakeTimers({ |
| 135 | + config: projectConfig, |
| 136 | + global: global as unknown as typeof globalThis, |
| 137 | + }); |
| 138 | + } |
| 139 | + |
| 140 | + // eslint-disable-next-line @typescript-eslint/no-empty-function |
| 141 | + async setup(): Promise<void> {} |
| 142 | + |
| 143 | + async teardown(): Promise<void> { |
| 144 | + if (this.fakeTimers) { |
| 145 | + this.fakeTimers.dispose(); |
| 146 | + } |
| 147 | + if (this.fakeTimersModern) { |
| 148 | + this.fakeTimersModern.dispose(); |
| 149 | + } |
| 150 | + if (this.global != null) { |
| 151 | + if (this.errorEventListener) { |
| 152 | + this.global.removeEventListener('error', this.errorEventListener); |
| 153 | + } |
| 154 | + this.global.close(); |
| 155 | + } |
| 156 | + this.errorEventListener = null; |
| 157 | + // @ts-expect-error: this.global not allowed to be `null` |
| 158 | + this.global = null; |
| 159 | + this.dom = null; |
| 160 | + this.fakeTimers = null; |
| 161 | + this.fakeTimersModern = null; |
| 162 | + } |
| 163 | + |
| 164 | + exportConditions(): Array<string> { |
| 165 | + return this._configuredExportConditions ?? this.customExportConditions; |
| 166 | + } |
| 167 | + |
| 168 | + getVmContext(): Context | null { |
| 169 | + if (this.dom) { |
| 170 | + return this.dom.getInternalVMContext(); |
| 171 | + } |
| 172 | + |
| 173 | + return null; |
| 174 | + } |
| 175 | +} |
0 commit comments