Skip to content

Commit 6045a96

Browse files
authored
feat: add custom jsdom env (#2904)
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
1 parent 9012e71 commit 6045a96

19 files changed

+592
-33
lines changed

Diff for: .npmignore

-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ commitlint.config.js
3636
.prettierrc
3737

3838
# Internal jest config
39-
jest.config.js
4039
jest*.config.ts
4140

4241
# Tsconfig
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { FooComponent } from '../foo.component';
4+
5+
describe('FooComponent', () => {
6+
it('should trigger change detection without fixture.detectChanges', () => {
7+
TestBed.configureTestingModule({
8+
imports: [FooComponent],
9+
});
10+
const fixture = TestBed.createComponent(FooComponent);
11+
12+
expect(fixture.componentInstance.value1()).toBe('val1');
13+
14+
fixture.componentRef.setInput('value1', 'hello');
15+
16+
expect(fixture.componentInstance.value1()).toBe('hello');
17+
});
18+
});

Diff for: e2e/custom-jsdom-env/foo.component.html

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!-- SOMETHING -->
2+
<p>Line 1</p>
3+
<div>
4+
<div *ngIf="condition1">
5+
{{ value1() }}
6+
</div>
7+
<span *ngIf="condition2">
8+
{{ value2() }}
9+
</span>
10+
</div>

Diff for: e2e/custom-jsdom-env/foo.component.scss

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
p {
2+
font-size: 1.6rem;
3+
}

Diff for: e2e/custom-jsdom-env/foo.component.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { NgIf } from '@angular/common';
2+
import { Component, input } from '@angular/core';
3+
4+
@Component({
5+
selector: 'foo',
6+
standalone: true,
7+
templateUrl: './foo.component.html',
8+
styleUrls: ['./foo.component.scss'],
9+
// we have to setup styles this way, since simple styles/styleUrs properties will be removed (jest does not unit test styles)
10+
styles: [
11+
`
12+
p {
13+
color: red;
14+
}
15+
`,
16+
],
17+
imports: [NgIf],
18+
})
19+
export class FooComponent {
20+
readonly value1 = input('val1');
21+
readonly value2 = input('val2');
22+
23+
protected readonly condition1 = true;
24+
protected readonly condition2 = false;
25+
}

Diff for: e2e/custom-jsdom-env/jest-cjs.config.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { JestConfigWithTsJest } from 'ts-jest';
2+
3+
const config: JestConfigWithTsJest = {
4+
displayName: 'e2e-custom-jsdom-env',
5+
testEnvironment: '<rootDir>/../../environments/jest-jsdom-env.js',
6+
setupFilesAfterEnv: ['<rootDir>/../setup-test-env.ts'],
7+
transform: {
8+
'^.+\\.(ts|mjs|js|html)$': [
9+
'<rootDir>/../../build/index.js',
10+
{
11+
tsconfig: '<rootDir>/tsconfig-cjs.spec.json',
12+
stringifyContentPathRegex: '\\.(html|svg)$',
13+
},
14+
],
15+
},
16+
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
17+
};
18+
19+
export default config;

Diff for: e2e/custom-jsdom-env/jest-esm.config.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { JestConfigWithTsJest } from 'ts-jest';
2+
3+
const config: JestConfigWithTsJest = {
4+
displayName: 'e2e-custom-jsdom-env',
5+
testEnvironment: '<rootDir>/../../environments/jest-jsdom-env.js',
6+
setupFilesAfterEnv: ['<rootDir>/../setup-test-env.mts'],
7+
moduleNameMapper: {
8+
rxjs: '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
9+
},
10+
extensionsToTreatAsEsm: ['.ts', '.mts'],
11+
transform: {
12+
'^.+\\.(ts|mts|mjs|js|html)$': [
13+
'<rootDir>/../../build/index.js',
14+
{
15+
useESM: true,
16+
tsconfig: '<rootDir>/tsconfig-esm.spec.json',
17+
stringifyContentPathRegex: '\\.(html|svg)$',
18+
},
19+
],
20+
},
21+
};
22+
23+
export default config;

Diff for: e2e/custom-jsdom-env/jest-transpile-cjs.config.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { JestConfigWithTsJest } from 'ts-jest';
2+
3+
const config: JestConfigWithTsJest = {
4+
displayName: 'e2e-custom-jsdom-env',
5+
testEnvironment: '<rootDir>/../../environments/jest-jsdom-env.js',
6+
setupFilesAfterEnv: ['<rootDir>/../setup-test-env.ts'],
7+
transform: {
8+
'^.+\\.(ts|mjs|js|html)$': [
9+
'<rootDir>/../../build/index.js',
10+
{
11+
tsconfig: '<rootDir>/tsconfig-cjs.spec.json',
12+
stringifyContentPathRegex: '\\.(html|svg)$',
13+
isolatedModules: true,
14+
},
15+
],
16+
},
17+
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
18+
};
19+
20+
export default config;

Diff for: e2e/custom-jsdom-env/jest-transpile-esm.config.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { JestConfigWithTsJest } from 'ts-jest';
2+
3+
const config: JestConfigWithTsJest = {
4+
displayName: 'e2e-custom-jsdom-env',
5+
testEnvironment: '<rootDir>/../../environments/jest-jsdom-env.js',
6+
setupFilesAfterEnv: ['<rootDir>/../setup-test-env.mts'],
7+
moduleNameMapper: {
8+
rxjs: '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
9+
},
10+
extensionsToTreatAsEsm: ['.ts', '.mts'],
11+
transform: {
12+
'^.+\\.(ts|mts|mjs|js|html)$': [
13+
'<rootDir>/../../build/index.js',
14+
{
15+
useESM: true,
16+
tsconfig: '<rootDir>/tsconfig-esm.spec.json',
17+
stringifyContentPathRegex: '\\.(html|svg)$',
18+
isolatedModules: true,
19+
},
20+
],
21+
},
22+
};
23+
24+
export default config;

Diff for: e2e/custom-jsdom-env/tsconfig-cjs.spec.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "../../tsconfig-base.spec.json"
3+
}

Diff for: e2e/custom-jsdom-env/tsconfig-esm.spec.json

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "../../tsconfig-base.spec.json",
3+
"compilerOptions": {
4+
"module": "ES2022",
5+
"esModuleInterop": true
6+
}
7+
}

Diff for: environments/jest-jsdom-env.d.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { EnvironmentContext, JestEnvironmentConfig } from '@jest/environment';
2+
3+
import BaseEnv from '../build/environments/jest-env-jsdom-abstract';
4+
5+
export default class JestJSDOMEnvironment extends BaseEnv {
6+
constructor(config: JestEnvironmentConfig, context: EnvironmentContext);
7+
}

Diff for: environments/jest-jsdom-env.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const jestJsdomEnv = require('../build/environments/jest-jsdom-env');
2+
3+
module.exports = jestJsdomEnv;

Diff for: package.json

+10-3
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@
4949
"dependencies": {
5050
"bs-logger": "^0.2.6",
5151
"esbuild-wasm": ">=0.15.13",
52-
"jest-environment-jsdom": "^29.0.0",
53-
"jest-util": "^29.0.0",
54-
"pretty-format": "^29.0.0",
52+
"jest-environment-jsdom": "^29.7.0",
53+
"jest-util": "^29.7.0",
54+
"pretty-format": "^29.7.0",
5555
"ts-jest": "^29.0.0"
5656
},
5757
"optionalDependencies": {
@@ -62,8 +62,14 @@
6262
"@angular/core": ">=15.0.0 <20.0.0",
6363
"@angular/platform-browser-dynamic": ">=15.0.0 <20.0.0",
6464
"jest": "^29.0.0",
65+
"jsdom": ">=20.0.0 <=26.0.0",
6566
"typescript": ">=4.8"
6667
},
68+
"peerDependenciesMeta": {
69+
"jsdom": {
70+
"optional": true
71+
}
72+
},
6773
"devDependencies": {
6874
"@angular-devkit/build-angular": "^19.0.6",
6975
"@angular-eslint/eslint-plugin": "^18.4.3",
@@ -106,6 +112,7 @@
106112
"glob": "^10.4.5",
107113
"husky": "^9.1.7",
108114
"jest": "^29.7.0",
115+
"jsdom": "^25.0.1",
109116
"pinst": "^3.0.0",
110117
"prettier": "^2.8.8",
111118
"rimraf": "^5.0.10",

Diff for: src/environments/jest-env-jsdom-abstract.ts

+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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

Comments
 (0)