Skip to content

feat: add custom jsdom env #2904

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading