@happy-dom/jest-environment
Version:
Use Happy DOM as environment in Jest.
193 lines (168 loc) • 6.15 kB
text/typescript
/* eslint-disable filenames/match-exported */
import type VM from 'vm';
import * as JestUtil from 'jest-util';
import { ModuleMocker } from 'jest-mock';
import { LegacyFakeTimers, ModernFakeTimers } from '@jest/fake-timers';
import type { JestEnvironment, EnvironmentContext } from '@jest/environment';
import type { IOptionalBrowserSettings } from 'happy-dom';
import { Window, BrowserErrorCaptureEnum } from 'happy-dom';
import type { Script } from 'vm';
import type { Global, Config } from '@jest/types';
/**
* Happy DOM Jest Environment.
*/
export default class HappyDOMEnvironment implements JestEnvironment {
public fakeTimers: LegacyFakeTimers<number> | null = null;
public fakeTimersModern: ModernFakeTimers | null = null;
public window: Window;
public global: Global.Global;
public moduleMocker: ModuleMocker;
/**
* jest-environment-jsdom" has the default set to ['browser']
* As changing this value would be a breaking change, we will keep it at ['node', 'node-addons'] until we do a major release
*
* @see https://stackoverflow.com/questions/72428323/jest-referenceerror-vue-is-not-defined
*/
public customExportConditions = ['node', 'node-addons'];
private _configuredExportConditions: string[];
/**
* Constructor.
*
* @param config Jest config.
* @param config.globalConfig jest global config.
* @param config.projectConfig jest project config.
* @param options Options.
*/
constructor(
config:
| { globalConfig: Config.GlobalConfig; projectConfig: Config.ProjectConfig }
| Config.ProjectConfig,
options?: EnvironmentContext
) {
let projectConfig: Config.ProjectConfig;
let globals: Config.ConfigGlobals;
if (isJestConfigVersion29(config)) {
// Jest 29
globals = config.globals;
projectConfig = config;
} else if (isJestConfigVersion28(config)) {
// Jest < 29
globals = config.projectConfig.globals;
projectConfig = config.projectConfig;
} else {
throw new Error('Unsupported jest version.');
}
this._configuredExportConditions = [];
if ('customExportConditions' in projectConfig.testEnvironmentOptions) {
const { customExportConditions } = projectConfig.testEnvironmentOptions;
if (
Array.isArray(customExportConditions) &&
customExportConditions.every((condition) => typeof condition === 'string')
) {
this._configuredExportConditions = customExportConditions;
} else {
throw new Error('Custom export conditions specified but they are not an array of strings');
}
}
// Initialize Window and Global
this.window = new Window({
url: 'http://localhost/',
...projectConfig.testEnvironmentOptions,
console: options?.console || console,
settings: {
...(<IOptionalBrowserSettings>projectConfig.testEnvironmentOptions?.settings),
errorCapture: BrowserErrorCaptureEnum.disabled
}
});
this.global = <Global.Global>(<unknown>this.window);
this.moduleMocker = new ModuleMocker(<typeof globalThis>(<unknown>this.window));
// Node's error-message stack size is limited to 10, but it's pretty useful to see more than that when a test fails.
this.global.Error.stackTraceLimit = 100;
// TODO: Remove this ASAP as it currently causes tests to run really slow.
this.global.Buffer = Buffer;
// Needed as Jest is using it
this.window['global'] = this.global;
JestUtil.installCommonGlobals(<typeof globalThis>(<unknown>this.window), globals);
// For some reason Jest removes the global setImmediate and clearImmediate, so we need to add them back.
// This is especially important for Jest 30+ where these are no longer available.
this.global.setImmediate = global.setImmediate;
this.global.clearImmediate = global.clearImmediate;
this.fakeTimers = new LegacyFakeTimers({
config: projectConfig,
global: <typeof globalThis>(<unknown>this.window),
moduleMocker: this.moduleMocker,
timerConfig: {
idToRef: (id: number) => id,
refToId: (ref: number) => ref
}
});
this.fakeTimersModern = new ModernFakeTimers({
config: projectConfig,
global: <typeof globalThis>(<unknown>this.window)
});
// Jest is using the setTimeout function from Happy DOM internally for detecting when a test times out, but this causes window.happyDOM?.waitUntilComplete() and window.happyDOM?.abort() to not work as expected.
// Hopefully Jest can fix this in the future as this fix is not very pretty.
const happyDOMSetTimeout = this.global.setTimeout;
(<(...args: unknown[]) => number>this.global.setTimeout) = (...args: unknown[]): number => {
if (new Error('stack').stack!.includes('/jest-jasmine')) {
// @ts-ignore
return global.setTimeout.call(global, ...args);
}
// @ts-ignore
return happyDOMSetTimeout.call(this.global, ...args);
};
}
/**
* Respect any export conditions specified as options
* https://jestjs.io/docs/configuration#testenvironmentoptions-object
*/
public exportConditions(): string[] {
return this._configuredExportConditions ?? this.customExportConditions;
}
/**
* Setup.
*
* @returns Promise.
*/
public async setup(): Promise<void> {}
/**
* Teardown.
*
* @returns Promise.
*/
public async teardown(): Promise<void> {
this.fakeTimers!.dispose();
this.fakeTimersModern!.dispose();
await (<Window>(<unknown>this.global)).happyDOM.abort();
(<Window>(<unknown>this.global)).close();
this.global = null!;
this.moduleMocker = null!;
this.fakeTimers = null;
this.fakeTimersModern = null;
}
/**
* Runs a script.
*
* @param script Script.
* @returns Result.
*/
public runScript(script: Script): null {
return script.runInContext(this.global);
}
/**
* Returns the VM context.
*
* @returns Context.
*/
public getVmContext(): VM.Context {
return this.global;
}
}
function isJestConfigVersion29(config: unknown): config is Config.ProjectConfig {
return Object.getOwnPropertyDescriptor(config, 'globals') !== undefined;
}
function isJestConfigVersion28(
config: unknown
): config is { globalConfig: Config.GlobalConfig; projectConfig: Config.ProjectConfig } {
return Object.getOwnPropertyDescriptor(config, 'projectConfig') !== undefined;
}