Skip to content

Commit

Permalink
feat: add custom jsdom env (#2904)
Browse files Browse the repository at this point in the history
This environment is a copy of `jest-environment-jsdom` which allows end users to decide which version of `jsdom` they would like to use in their project. See more in documentation site at https://thymikee.github.io/jest-preset-angular/docs/guides/jsdom-version

Closes #2883
  • Loading branch information
ahnpnl authored Dec 20, 2024
1 parent 9012e71 commit 6045a96
Show file tree
Hide file tree
Showing 19 changed files with 592 additions and 33 deletions.
1 change: 0 additions & 1 deletion .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ commitlint.config.js
.prettierrc

# Internal jest config
jest.config.js
jest*.config.ts

# Tsconfig
Expand Down
18 changes: 18 additions & 0 deletions e2e/custom-jsdom-env/__tests__/custom-jsdom-env.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';

import { FooComponent } from '../foo.component';

describe('FooComponent', () => {
it('should trigger change detection without fixture.detectChanges', () => {
TestBed.configureTestingModule({
imports: [FooComponent],
});
const fixture = TestBed.createComponent(FooComponent);

expect(fixture.componentInstance.value1()).toBe('val1');

fixture.componentRef.setInput('value1', 'hello');

expect(fixture.componentInstance.value1()).toBe('hello');
});
});
10 changes: 10 additions & 0 deletions e2e/custom-jsdom-env/foo.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!-- SOMETHING -->
<p>Line 1</p>
<div>
<div *ngIf="condition1">
{{ value1() }}
</div>
<span *ngIf="condition2">
{{ value2() }}
</span>
</div>
3 changes: 3 additions & 0 deletions e2e/custom-jsdom-env/foo.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
p {
font-size: 1.6rem;
}
25 changes: 25 additions & 0 deletions e2e/custom-jsdom-env/foo.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NgIf } from '@angular/common';
import { Component, input } from '@angular/core';

@Component({
selector: 'foo',
standalone: true,
templateUrl: './foo.component.html',
styleUrls: ['./foo.component.scss'],
// we have to setup styles this way, since simple styles/styleUrs properties will be removed (jest does not unit test styles)
styles: [
`
p {
color: red;
}
`,
],
imports: [NgIf],
})
export class FooComponent {
readonly value1 = input('val1');
readonly value2 = input('val2');

protected readonly condition1 = true;
protected readonly condition2 = false;
}
19 changes: 19 additions & 0 deletions e2e/custom-jsdom-env/jest-cjs.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { JestConfigWithTsJest } from 'ts-jest';

const config: JestConfigWithTsJest = {
displayName: 'e2e-custom-jsdom-env',
testEnvironment: '<rootDir>/../../environments/jest-jsdom-env.js',
setupFilesAfterEnv: ['<rootDir>/../setup-test-env.ts'],
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'<rootDir>/../../build/index.js',
{
tsconfig: '<rootDir>/tsconfig-cjs.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
};

export default config;
23 changes: 23 additions & 0 deletions e2e/custom-jsdom-env/jest-esm.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { JestConfigWithTsJest } from 'ts-jest';

const config: JestConfigWithTsJest = {
displayName: 'e2e-custom-jsdom-env',
testEnvironment: '<rootDir>/../../environments/jest-jsdom-env.js',
setupFilesAfterEnv: ['<rootDir>/../setup-test-env.mts'],
moduleNameMapper: {
rxjs: '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
},
extensionsToTreatAsEsm: ['.ts', '.mts'],
transform: {
'^.+\\.(ts|mts|mjs|js|html)$': [
'<rootDir>/../../build/index.js',
{
useESM: true,
tsconfig: '<rootDir>/tsconfig-esm.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
};

export default config;
20 changes: 20 additions & 0 deletions e2e/custom-jsdom-env/jest-transpile-cjs.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { JestConfigWithTsJest } from 'ts-jest';

const config: JestConfigWithTsJest = {
displayName: 'e2e-custom-jsdom-env',
testEnvironment: '<rootDir>/../../environments/jest-jsdom-env.js',
setupFilesAfterEnv: ['<rootDir>/../setup-test-env.ts'],
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'<rootDir>/../../build/index.js',
{
tsconfig: '<rootDir>/tsconfig-cjs.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
isolatedModules: true,
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
};

export default config;
24 changes: 24 additions & 0 deletions e2e/custom-jsdom-env/jest-transpile-esm.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { JestConfigWithTsJest } from 'ts-jest';

const config: JestConfigWithTsJest = {
displayName: 'e2e-custom-jsdom-env',
testEnvironment: '<rootDir>/../../environments/jest-jsdom-env.js',
setupFilesAfterEnv: ['<rootDir>/../setup-test-env.mts'],
moduleNameMapper: {
rxjs: '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
},
extensionsToTreatAsEsm: ['.ts', '.mts'],
transform: {
'^.+\\.(ts|mts|mjs|js|html)$': [
'<rootDir>/../../build/index.js',
{
useESM: true,
tsconfig: '<rootDir>/tsconfig-esm.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
isolatedModules: true,
},
],
},
};

export default config;
3 changes: 3 additions & 0 deletions e2e/custom-jsdom-env/tsconfig-cjs.spec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig-base.spec.json"
}
7 changes: 7 additions & 0 deletions e2e/custom-jsdom-env/tsconfig-esm.spec.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig-base.spec.json",
"compilerOptions": {
"module": "ES2022",
"esModuleInterop": true
}
}
7 changes: 7 additions & 0 deletions environments/jest-jsdom-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { EnvironmentContext, JestEnvironmentConfig } from '@jest/environment';

import BaseEnv from '../build/environments/jest-env-jsdom-abstract';

export default class JestJSDOMEnvironment extends BaseEnv {
constructor(config: JestEnvironmentConfig, context: EnvironmentContext);
}
3 changes: 3 additions & 0 deletions environments/jest-jsdom-env.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const jestJsdomEnv = require('../build/environments/jest-jsdom-env');

module.exports = jestJsdomEnv;
13 changes: 10 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@
"dependencies": {
"bs-logger": "^0.2.6",
"esbuild-wasm": ">=0.15.13",
"jest-environment-jsdom": "^29.0.0",
"jest-util": "^29.0.0",
"pretty-format": "^29.0.0",
"jest-environment-jsdom": "^29.7.0",
"jest-util": "^29.7.0",
"pretty-format": "^29.7.0",
"ts-jest": "^29.0.0"
},
"optionalDependencies": {
Expand All @@ -62,8 +62,14 @@
"@angular/core": ">=15.0.0 <20.0.0",
"@angular/platform-browser-dynamic": ">=15.0.0 <20.0.0",
"jest": "^29.0.0",
"jsdom": ">=20.0.0 <=26.0.0",
"typescript": ">=4.8"
},
"peerDependenciesMeta": {
"jsdom": {
"optional": true
}
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.6",
"@angular-eslint/eslint-plugin": "^18.4.3",
Expand Down Expand Up @@ -106,6 +112,7 @@
"glob": "^10.4.5",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jsdom": "^25.0.1",
"pinst": "^3.0.0",
"prettier": "^2.8.8",
"rimraf": "^5.0.10",
Expand Down
175 changes: 175 additions & 0 deletions src/environments/jest-env-jsdom-abstract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/* eslint-disable */
// @ts-nocheck TODO: replace with `@jest/environment-jsdom-abstract` package when Jest 30 is released

/**
* 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 'node:vm';

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';
import type * as jsdom from 'jsdom';

// 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');
}

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);

// 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;
}
}
Loading

0 comments on commit 6045a96

Please sign in to comment.