diff --git a/examples/example-app-v11/jest-esm.config.mjs b/examples/example-app-v11/jest-esm.config.mjs index b4aec6e52e..32a268f7f9 100644 --- a/examples/example-app-v11/jest-esm.config.mjs +++ b/examples/example-app-v11/jest-esm.config.mjs @@ -1,16 +1,16 @@ -import ngPreset from 'jest-preset-angular/presets/index.js'; - globalThis.ngJest = { skipNgcc: false, + experimentalPrecompilation: true, tsconfig: 'tsconfig-esm.spec.json', -}; +} /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ const jestConfig = { - ...ngPreset.defaultsESM, + preset: 'jest-preset-angular/presets/defaults-esm', globals: { 'ts-jest': { - ...ngPreset.defaultsESM.globals["ts-jest"], + useESM: true, + stringifyContentPathRegex: '\\.(html|svg)$', tsconfig: '/tsconfig-esm.spec.json', }, }, diff --git a/examples/example-app-v11/jest.config.js b/examples/example-app-v11/jest.config.js index fa4c34012d..aca5dc98ae 100644 --- a/examples/example-app-v11/jest.config.js +++ b/examples/example-app-v11/jest.config.js @@ -5,6 +5,7 @@ const { paths } = require('./tsconfig.json').compilerOptions; globalThis.ngJest = { skipNgcc: false, tsconfig: 'tsconfig.spec.json', + experimentalPrecompilation: true, }; /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ diff --git a/examples/example-app-v11/src/app/app-services/app.service.spec.ts b/examples/example-app-v11/src/app/app-services/app.service.spec.ts new file mode 100644 index 0000000000..3a3b746f4a --- /dev/null +++ b/examples/example-app-v11/src/app/app-services/app.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AppService } from './app.service'; + +describe('AppService', () => { + let service: AppService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AppService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/examples/example-app-v11/src/app/app-services/app.service.ts b/examples/example-app-v11/src/app/app-services/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-v11/src/app/app-services/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-v11/src/app/app-services/app.tokens.ts b/examples/example-app-v11/src/app/app-services/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-v11/src/app/app-services/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-v11/src/app/app-services/index.ts b/examples/example-app-v11/src/app/app-services/index.ts new file mode 100644 index 0000000000..ee297abd78 --- /dev/null +++ b/examples/example-app-v11/src/app/app-services/index.ts @@ -0,0 +1,3 @@ +import * as Services from './app.service'; + +export { Services }; diff --git a/examples/example-app-v11/src/app/app.component.ts b/examples/example-app-v11/src/app/app.component.ts index 9222f6bc69..15b51ab70d 100644 --- a/examples/example-app-v11/src/app/app.component.ts +++ b/examples/example-app-v11/src/app/app.component.ts @@ -1,7 +1,11 @@ import { Component } from '@angular/core'; +import { Services } from './app-services'; + @Component({ selector: 'app-root', templateUrl: './app.component.html', }) -export class AppComponent {} +export class AppComponent { + constructor(private myService: Services.AppService) {} +} diff --git a/examples/example-app-v11/tsconfig-esm.spec.json b/examples/example-app-v11/tsconfig-esm.spec.json index aa072bd044..e92562235d 100644 --- a/examples/example-app-v11/tsconfig-esm.spec.json +++ b/examples/example-app-v11/tsconfig-esm.spec.json @@ -1,3 +1,4 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ { "extends": "./tsconfig.json", "compilerOptions": { diff --git a/examples/example-app-v12-monorepo/jest-esm-isolated.config.mjs b/examples/example-app-v12-monorepo/jest-esm-isolated.config.mjs index 8d190e3ac7..ef996bb995 100644 --- a/examples/example-app-v12-monorepo/jest-esm-isolated.config.mjs +++ b/examples/example-app-v12-monorepo/jest-esm-isolated.config.mjs @@ -1,6 +1,7 @@ globalThis.ngJest = { skipNgcc: false, tsconfig: 'tsconfig-esm.spec.json', + experimentalPrecompilation: true, }; /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ diff --git a/examples/example-app-v12-monorepo/jest-esm.config.mjs b/examples/example-app-v12-monorepo/jest-esm.config.mjs index 6888fed4f9..b37472cdce 100644 --- a/examples/example-app-v12-monorepo/jest-esm.config.mjs +++ b/examples/example-app-v12-monorepo/jest-esm.config.mjs @@ -1,6 +1,7 @@ globalThis.ngJest = { skipNgcc: false, tsconfig: 'tsconfig-esm.spec.json', + experimentalPrecompilation: true, }; /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ diff --git a/examples/example-app-v12-monorepo/jest-isolated.config.js b/examples/example-app-v12-monorepo/jest-isolated.config.js index f23c8c4b2c..db9906344c 100644 --- a/examples/example-app-v12-monorepo/jest-isolated.config.js +++ b/examples/example-app-v12-monorepo/jest-isolated.config.js @@ -2,6 +2,7 @@ globalThis.ngJest = { skipNgcc: false, tsconfig: 'tsconfig.spec.json', + experimentalPrecompilation: true, }; /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ diff --git a/examples/example-app-v12-monorepo/jest.config.js b/examples/example-app-v12-monorepo/jest.config.js index aa4af46a21..112836cbbd 100644 --- a/examples/example-app-v12-monorepo/jest.config.js +++ b/examples/example-app-v12-monorepo/jest.config.js @@ -2,6 +2,7 @@ globalThis.ngJest = { skipNgcc: false, tsconfig: 'tsconfig.spec.json', + experimentalPrecompilation: true, }; /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ diff --git a/examples/example-app-v12-monorepo/projects/app1/jest-esm.config.mjs b/examples/example-app-v12-monorepo/projects/app1/jest-esm.config.mjs index df1e184328..32a268f7f9 100644 --- a/examples/example-app-v12-monorepo/projects/app1/jest-esm.config.mjs +++ b/examples/example-app-v12-monorepo/projects/app1/jest-esm.config.mjs @@ -1,3 +1,9 @@ +globalThis.ngJest = { + skipNgcc: false, + experimentalPrecompilation: true, + tsconfig: 'tsconfig-esm.spec.json', +} + /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ const jestConfig = { preset: 'jest-preset-angular/presets/defaults-esm', @@ -8,9 +14,7 @@ const jestConfig = { tsconfig: '/tsconfig-esm.spec.json', }, }, - moduleNameMapper: { - tslib: 'tslib/tslib.es6.js', - }, + globalSetup: 'jest-preset-angular/global-setup', setupFilesAfterEnv: ['/setup-jest.ts'], } diff --git a/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/app.service.spec.ts b/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/app.service.spec.ts new file mode 100644 index 0000000000..3a3b746f4a --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/app.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AppService } from './app.service'; + +describe('AppService', () => { + let service: AppService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AppService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/app.service.ts b/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/app.tokens.ts b/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/index.ts b/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/index.ts new file mode 100644 index 0000000000..ee297abd78 --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app1/src/app/app-services/index.ts @@ -0,0 +1,3 @@ +import * as Services from './app.service'; + +export { Services }; diff --git a/examples/example-app-v12-monorepo/projects/app1/src/app/app.component.ts b/examples/example-app-v12-monorepo/projects/app1/src/app/app.component.ts index 9222f6bc69..15b51ab70d 100644 --- a/examples/example-app-v12-monorepo/projects/app1/src/app/app.component.ts +++ b/examples/example-app-v12-monorepo/projects/app1/src/app/app.component.ts @@ -1,7 +1,11 @@ import { Component } from '@angular/core'; +import { Services } from './app-services'; + @Component({ selector: 'app-root', templateUrl: './app.component.html', }) -export class AppComponent {} +export class AppComponent { + constructor(private myService: Services.AppService) {} +} diff --git a/examples/example-app-v12-monorepo/projects/app1/src/app/app.service.ts b/examples/example-app-v12-monorepo/projects/app1/src/app/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app1/src/app/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-v12-monorepo/projects/app1/src/app/app.tokens.ts b/examples/example-app-v12-monorepo/projects/app1/src/app/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app1/src/app/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-v12-monorepo/projects/app2/jest-esm.config.mjs b/examples/example-app-v12-monorepo/projects/app2/jest-esm.config.mjs index df1e184328..32a268f7f9 100644 --- a/examples/example-app-v12-monorepo/projects/app2/jest-esm.config.mjs +++ b/examples/example-app-v12-monorepo/projects/app2/jest-esm.config.mjs @@ -1,3 +1,9 @@ +globalThis.ngJest = { + skipNgcc: false, + experimentalPrecompilation: true, + tsconfig: 'tsconfig-esm.spec.json', +} + /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ const jestConfig = { preset: 'jest-preset-angular/presets/defaults-esm', @@ -8,9 +14,7 @@ const jestConfig = { tsconfig: '/tsconfig-esm.spec.json', }, }, - moduleNameMapper: { - tslib: 'tslib/tslib.es6.js', - }, + globalSetup: 'jest-preset-angular/global-setup', setupFilesAfterEnv: ['/setup-jest.ts'], } diff --git a/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/app.service.spec.ts b/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/app.service.spec.ts new file mode 100644 index 0000000000..3a3b746f4a --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/app.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AppService } from './app.service'; + +describe('AppService', () => { + let service: AppService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AppService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/app.service.ts b/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/app.tokens.ts b/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/index.ts b/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/index.ts new file mode 100644 index 0000000000..ee297abd78 --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app2/src/app/app-services/index.ts @@ -0,0 +1,3 @@ +import * as Services from './app.service'; + +export { Services }; diff --git a/examples/example-app-v12-monorepo/projects/app2/src/app/app.component.ts b/examples/example-app-v12-monorepo/projects/app2/src/app/app.component.ts index 9222f6bc69..15b51ab70d 100644 --- a/examples/example-app-v12-monorepo/projects/app2/src/app/app.component.ts +++ b/examples/example-app-v12-monorepo/projects/app2/src/app/app.component.ts @@ -1,7 +1,11 @@ import { Component } from '@angular/core'; +import { Services } from './app-services'; + @Component({ selector: 'app-root', templateUrl: './app.component.html', }) -export class AppComponent {} +export class AppComponent { + constructor(private myService: Services.AppService) {} +} diff --git a/examples/example-app-v12-monorepo/projects/app2/src/app/app.service.ts b/examples/example-app-v12-monorepo/projects/app2/src/app/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app2/src/app/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-v12-monorepo/projects/app2/src/app/app.tokens.ts b/examples/example-app-v12-monorepo/projects/app2/src/app/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-v12-monorepo/projects/app2/src/app/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-v12/jest-esm.config.mjs b/examples/example-app-v12/jest-esm.config.mjs index b4aec6e52e..32a268f7f9 100644 --- a/examples/example-app-v12/jest-esm.config.mjs +++ b/examples/example-app-v12/jest-esm.config.mjs @@ -1,16 +1,16 @@ -import ngPreset from 'jest-preset-angular/presets/index.js'; - globalThis.ngJest = { skipNgcc: false, + experimentalPrecompilation: true, tsconfig: 'tsconfig-esm.spec.json', -}; +} /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ const jestConfig = { - ...ngPreset.defaultsESM, + preset: 'jest-preset-angular/presets/defaults-esm', globals: { 'ts-jest': { - ...ngPreset.defaultsESM.globals["ts-jest"], + useESM: true, + stringifyContentPathRegex: '\\.(html|svg)$', tsconfig: '/tsconfig-esm.spec.json', }, }, diff --git a/examples/example-app-v12/jest.config.js b/examples/example-app-v12/jest.config.js index fa4c34012d..aca5dc98ae 100644 --- a/examples/example-app-v12/jest.config.js +++ b/examples/example-app-v12/jest.config.js @@ -5,6 +5,7 @@ const { paths } = require('./tsconfig.json').compilerOptions; globalThis.ngJest = { skipNgcc: false, tsconfig: 'tsconfig.spec.json', + experimentalPrecompilation: true, }; /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ diff --git a/examples/example-app-v12/src/app/app-services/app.service.spec.ts b/examples/example-app-v12/src/app/app-services/app.service.spec.ts new file mode 100644 index 0000000000..91b988b53a --- /dev/null +++ b/examples/example-app-v12/src/app/app-services/app.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AppService } from '../app.service'; + +describe('AppService', () => { + let service: AppService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AppService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/examples/example-app-v12/src/app/app-services/app.service.ts b/examples/example-app-v12/src/app/app-services/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-v12/src/app/app-services/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-v12/src/app/app-services/app.tokens.ts b/examples/example-app-v12/src/app/app-services/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-v12/src/app/app-services/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-v12/src/app/app-services/index.ts b/examples/example-app-v12/src/app/app-services/index.ts new file mode 100644 index 0000000000..ee297abd78 --- /dev/null +++ b/examples/example-app-v12/src/app/app-services/index.ts @@ -0,0 +1,3 @@ +import * as Services from './app.service'; + +export { Services }; diff --git a/examples/example-app-v12/src/app/app.component.ts b/examples/example-app-v12/src/app/app.component.ts index 9222f6bc69..15b51ab70d 100644 --- a/examples/example-app-v12/src/app/app.component.ts +++ b/examples/example-app-v12/src/app/app.component.ts @@ -1,7 +1,11 @@ import { Component } from '@angular/core'; +import { Services } from './app-services'; + @Component({ selector: 'app-root', templateUrl: './app.component.html', }) -export class AppComponent {} +export class AppComponent { + constructor(private myService: Services.AppService) {} +} diff --git a/examples/example-app-v12/src/app/app.service.ts b/examples/example-app-v12/src/app/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-v12/src/app/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-v12/src/app/app.tokens.ts b/examples/example-app-v12/src/app/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-v12/src/app/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-v12/tsconfig-esm.spec.json b/examples/example-app-v12/tsconfig-esm.spec.json index aa072bd044..e92562235d 100644 --- a/examples/example-app-v12/tsconfig-esm.spec.json +++ b/examples/example-app-v12/tsconfig-esm.spec.json @@ -1,3 +1,4 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ { "extends": "./tsconfig.json", "compilerOptions": { diff --git a/examples/example-app-v13/jest-esm.config.mjs b/examples/example-app-v13/jest-esm.config.mjs index 30ef9b63ee..069e3d91a2 100644 --- a/examples/example-app-v13/jest-esm.config.mjs +++ b/examples/example-app-v13/jest-esm.config.mjs @@ -1,24 +1,24 @@ -import ngPreset from 'jest-preset-angular/presets/index.js'; - globalThis.ngJest = { skipNgcc: false, + experimentalPrecompilation: true, tsconfig: 'tsconfig-esm.spec.json', -}; +} /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ const jestConfig = { - ...ngPreset.defaultsESM, + preset: 'jest-preset-angular/presets/defaults-esm', globals: { 'ts-jest': { - ...ngPreset.defaultsESM.globals["ts-jest"], + useESM: true, + stringifyContentPathRegex: '\\.(html|svg)$', tsconfig: '/tsconfig-esm.spec.json', }, }, - globalSetup: 'jest-preset-angular/global-setup', moduleNameMapper: { tslib: 'tslib/tslib.es6.js', rxjs: '/node_modules/rxjs/dist/bundles/rxjs.umd.js', }, + globalSetup: 'jest-preset-angular/global-setup', setupFilesAfterEnv: ['/setup-jest.ts'], } diff --git a/examples/example-app-v13/jest.config.js b/examples/example-app-v13/jest.config.js index fa4c34012d..aca5dc98ae 100644 --- a/examples/example-app-v13/jest.config.js +++ b/examples/example-app-v13/jest.config.js @@ -5,6 +5,7 @@ const { paths } = require('./tsconfig.json').compilerOptions; globalThis.ngJest = { skipNgcc: false, tsconfig: 'tsconfig.spec.json', + experimentalPrecompilation: true, }; /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ diff --git a/examples/example-app-v13/package.json b/examples/example-app-v13/package.json index 7b62891add..131dd56f6e 100644 --- a/examples/example-app-v13/package.json +++ b/examples/example-app-v13/package.json @@ -8,7 +8,7 @@ "test": "jest --no-cache", "test-isolated": "jest -c=jest-isolated.config.js --no-cache", "test-esm": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js -c=jest-esm.config.mjs --no-cache", - "test-esm-isolated": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js -c=jest-esm-isolated.config.mjs --no-cache --runInBand" + "test-esm-isolated": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js -c=jest-esm-isolated.config.mjs --no-cache" }, "private": true, "dependencies": { diff --git a/examples/example-app-v13/src/app/app-services/app.service.spec.ts b/examples/example-app-v13/src/app/app-services/app.service.spec.ts new file mode 100644 index 0000000000..3a3b746f4a --- /dev/null +++ b/examples/example-app-v13/src/app/app-services/app.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AppService } from './app.service'; + +describe('AppService', () => { + let service: AppService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AppService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/examples/example-app-v13/src/app/app-services/app.service.ts b/examples/example-app-v13/src/app/app-services/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-v13/src/app/app-services/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-v13/src/app/app-services/app.tokens.ts b/examples/example-app-v13/src/app/app-services/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-v13/src/app/app-services/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-v13/src/app/app-services/index.ts b/examples/example-app-v13/src/app/app-services/index.ts new file mode 100644 index 0000000000..ee297abd78 --- /dev/null +++ b/examples/example-app-v13/src/app/app-services/index.ts @@ -0,0 +1,3 @@ +import * as Services from './app.service'; + +export { Services }; diff --git a/examples/example-app-v13/src/app/app.component.ts b/examples/example-app-v13/src/app/app.component.ts index 9222f6bc69..15b51ab70d 100644 --- a/examples/example-app-v13/src/app/app.component.ts +++ b/examples/example-app-v13/src/app/app.component.ts @@ -1,7 +1,11 @@ import { Component } from '@angular/core'; +import { Services } from './app-services'; + @Component({ selector: 'app-root', templateUrl: './app.component.html', }) -export class AppComponent {} +export class AppComponent { + constructor(private myService: Services.AppService) {} +} diff --git a/examples/example-app-v13/src/app/app.service.ts b/examples/example-app-v13/src/app/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-v13/src/app/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-v13/src/app/app.tokens.ts b/examples/example-app-v13/src/app/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-v13/src/app/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-v13/tsconfig-esm.spec.json b/examples/example-app-v13/tsconfig-esm.spec.json index aa072bd044..e92562235d 100644 --- a/examples/example-app-v13/tsconfig-esm.spec.json +++ b/examples/example-app-v13/tsconfig-esm.spec.json @@ -1,3 +1,4 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ { "extends": "./tsconfig.json", "compilerOptions": { diff --git a/examples/example-app-yarn-workspace/packages/angular-app/jest-esm.config.mjs b/examples/example-app-yarn-workspace/packages/angular-app/jest-esm.config.mjs index b4aec6e52e..b5957c3cdb 100644 --- a/examples/example-app-yarn-workspace/packages/angular-app/jest-esm.config.mjs +++ b/examples/example-app-yarn-workspace/packages/angular-app/jest-esm.config.mjs @@ -1,20 +1,21 @@ -import ngPreset from 'jest-preset-angular/presets/index.js'; - globalThis.ngJest = { skipNgcc: false, + experimentalPrecompilation: false, tsconfig: 'tsconfig-esm.spec.json', -}; +} /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ const jestConfig = { - ...ngPreset.defaultsESM, + preset: 'jest-preset-angular/presets/defaults-esm', globals: { 'ts-jest': { - ...ngPreset.defaultsESM.globals["ts-jest"], + useESM: true, + stringifyContentPathRegex: '\\.(html|svg)$', tsconfig: '/tsconfig-esm.spec.json', }, }, globalSetup: 'jest-preset-angular/global-setup', + resolver: 'jest-preset-angular/build/resolvers/ng-jest-resolver', setupFilesAfterEnv: ['/setup-jest.ts'], } diff --git a/examples/example-app-yarn-workspace/packages/angular-app/jest.config.js b/examples/example-app-yarn-workspace/packages/angular-app/jest.config.js index fa4c34012d..768219cf42 100644 --- a/examples/example-app-yarn-workspace/packages/angular-app/jest.config.js +++ b/examples/example-app-yarn-workspace/packages/angular-app/jest.config.js @@ -5,12 +5,14 @@ const { paths } = require('./tsconfig.json').compilerOptions; globalThis.ngJest = { skipNgcc: false, tsconfig: 'tsconfig.spec.json', + experimentalPrecompilation: true, }; /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ module.exports = { preset: 'jest-preset-angular', globalSetup: 'jest-preset-angular/global-setup', + resolver: 'jest-preset-angular/build/resolvers/ng-jest-resolver', moduleNameMapper: pathsToModuleNameMapper(paths, { prefix: '' }), setupFilesAfterEnv: ['/setup-jest.ts'], }; diff --git a/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/app.service.spec.ts b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/app.service.spec.ts new file mode 100644 index 0000000000..3a3b746f4a --- /dev/null +++ b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/app.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AppService } from './app.service'; + +describe('AppService', () => { + let service: AppService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AppService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/app.service.ts b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/app.tokens.ts b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/index.ts b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/index.ts new file mode 100644 index 0000000000..ee297abd78 --- /dev/null +++ b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app-services/index.ts @@ -0,0 +1,3 @@ +import * as Services from './app.service'; + +export { Services }; diff --git a/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.component.ts b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.component.ts index 9222f6bc69..15b51ab70d 100644 --- a/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.component.ts +++ b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.component.ts @@ -1,7 +1,11 @@ import { Component } from '@angular/core'; +import { Services } from './app-services'; + @Component({ selector: 'app-root', templateUrl: './app.component.html', }) -export class AppComponent {} +export class AppComponent { + constructor(private myService: Services.AppService) {} +} diff --git a/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.service.ts b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.service.ts new file mode 100644 index 0000000000..7620f97982 --- /dev/null +++ b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.service.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import type { Request } from 'express'; + +import { REQUEST } from './app.tokens'; + +@Injectable({ + providedIn: 'root', +}) +export class AppService { + constructor(@Inject(REQUEST) @Optional() private request: Request) {} +} diff --git a/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.tokens.ts b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.tokens.ts new file mode 100644 index 0000000000..63bb84facb --- /dev/null +++ b/examples/example-app-yarn-workspace/packages/angular-app/src/app/app.tokens.ts @@ -0,0 +1,4 @@ +import { InjectionToken } from '@angular/core'; +import type { Request } from 'express'; + +export const REQUEST = new InjectionToken('express-request'); diff --git a/examples/example-app-yarn-workspace/packages/angular-app/tsconfig-esm.spec.json b/examples/example-app-yarn-workspace/packages/angular-app/tsconfig-esm.spec.json index aa072bd044..e92562235d 100644 --- a/examples/example-app-yarn-workspace/packages/angular-app/tsconfig-esm.spec.json +++ b/examples/example-app-yarn-workspace/packages/angular-app/tsconfig-esm.spec.json @@ -1,3 +1,4 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ { "extends": "./tsconfig.json", "compilerOptions": { diff --git a/ng-jest-preprocessor.js b/ng-jest-preprocessor.js new file mode 100644 index 0000000000..c460ca7d8d --- /dev/null +++ b/ng-jest-preprocessor.js @@ -0,0 +1 @@ +module.exports = require('./build/compiler/ng-jest-preprocessor'); diff --git a/src/compiler/cache.ts b/src/compiler/cache.ts new file mode 100644 index 0000000000..4a65475b6d --- /dev/null +++ b/src/compiler/cache.ts @@ -0,0 +1,28 @@ +import type * as ts from 'typescript'; + +/** + * Copy from https://github.com/angular/angular-cli/blob/master/packages/ngtools/webpack/src/ivy/cache.ts + */ +export class SourceFileCache extends Map { + readonly #angularDiagnostics = new Map(); + + invalidate(file: string): void { + const sourceFile = this.get(file); + if (sourceFile) { + this.delete(file); + this.#angularDiagnostics.delete(sourceFile); + } + } + + updateAngularDiagnostics(sourceFile: ts.SourceFile, diagnostics: ts.Diagnostic[]): void { + if (diagnostics.length > 0) { + this.#angularDiagnostics.set(sourceFile, diagnostics); + } else { + this.#angularDiagnostics.delete(sourceFile); + } + } + + getAngularDiagnostics(sourceFile: ts.SourceFile): ts.Diagnostic[] | undefined { + return this.#angularDiagnostics.get(sourceFile); + } +} diff --git a/src/compiler/host.ts b/src/compiler/host.ts new file mode 100644 index 0000000000..926cd9d3eb --- /dev/null +++ b/src/compiler/host.ts @@ -0,0 +1,87 @@ +import ts from 'typescript'; + +import { normalizePath } from './paths'; + +export const augmentHostWithCaching = (host: ts.CompilerHost, cache: Map): void => { + const baseGetSourceFile = host.getSourceFile; + host.getSourceFile = function (fileName, languageVersion, onError, shouldCreateNewSourceFile, ...parameters) { + if (!shouldCreateNewSourceFile && cache.has(fileName)) { + return cache.get(fileName); + } + + const file = baseGetSourceFile.call(host, fileName, languageVersion, onError, true, ...parameters); + + if (file) { + cache.set(fileName, file); + } + + return file; + }; +}; + +/** + * Augments a TypeScript Compiler Host's resolveModuleNames function to collect dependencies + * of the containing file passed to the resolveModuleNames function. This process assumes + * that consumers of the Compiler Host will only call resolveModuleNames with modules that are + * actually present in a containing file. + * This process is a workaround for gathering a TypeScript SourceFile's dependencies as there + * is no currently exposed public method to do so. A BuilderProgram does have a `getAllDependencies` + * function. + */ +export const augmentHostWithDependencyCollection = ( + host: ts.CompilerHost, + dependencies: Map>, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void => { + if (host.resolveModuleNames) { + const baseResolveModuleNames = host.resolveModuleNames; + host.resolveModuleNames = function (moduleNames: string[], containingFile: string, ...parameters) { + const results = baseResolveModuleNames.call(host, moduleNames, containingFile, ...parameters); + + const containingFilePath = normalizePath(containingFile); + for (const result of results) { + if (result) { + const containingFileDependencies = dependencies.get(containingFilePath); + if (containingFileDependencies) { + containingFileDependencies.add(result.resolvedFileName); + } else { + dependencies.set(containingFilePath, new Set([result.resolvedFileName])); + } + } + } + + return results; + }; + } else { + host.resolveModuleNames = function ( + moduleNames: string[], + containingFile: string, + _reusedNames: string[] | undefined, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ) { + return moduleNames.map((name) => { + const result = ts.resolveModuleName( + name, + containingFile, + options, + host, + moduleResolutionCache, + redirectedReference, + ).resolvedModule; + + if (result) { + const containingFilePath = normalizePath(containingFile); + const containingFileDependencies = dependencies.get(containingFilePath); + if (containingFileDependencies) { + containingFileDependencies.add(result.resolvedFileName); + } else { + dependencies.set(containingFilePath, new Set([result.resolvedFileName])); + } + } + + return result; + }); + }; + } +}; diff --git a/src/compiler/ng-jest-diagnostics.ts b/src/compiler/ng-jest-diagnostics.ts new file mode 100644 index 0000000000..aa016d8907 --- /dev/null +++ b/src/compiler/ng-jest-diagnostics.ts @@ -0,0 +1,76 @@ +import ts from 'typescript'; + +const ERROR_CODE_MATCHER = /(\u001b\[\d+m ?)TS-99(\d+: ?\u001b\[\d+m)/g; + +/** + * During formatting of `ts.Diagnostic`s, the numeric code of each diagnostic is prefixed with the + * hard-coded "TS" prefix. For Angular's own error codes, a prefix of "NG" is desirable. To achieve + * this, all Angular error codes start with "-99" so that the sequence "TS-99" can be assumed to + * correspond with an Angular specific error code. This function replaces those occurrences with + * just "NG". + * + * @param errors The formatted diagnostics + */ +const replaceTsWithNgInErrors = (errors: string): string => { + return errors.replace(ERROR_CODE_MATCHER, '$1NG$2'); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const isTsDiagnostic = (diagnostic: any): diagnostic is ts.Diagnostic => { + return !!diagnostic && diagnostic.source !== 'angular'; +}; + +const defaultFormatHost: ts.FormatDiagnosticsHost = { + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + getCanonicalFileName: (fileName) => fileName, + getNewLine: () => ts.sys.newLine, +}; + +type Diagnostics = readonly ts.Diagnostic[]; + +export const formatDiagnostics = (diags: Diagnostics, host: ts.FormatDiagnosticsHost = defaultFormatHost): string => { + if (diags.length) { + return diags + .map((diagnostic) => { + if (isTsDiagnostic(diagnostic)) { + return replaceTsWithNgInErrors(ts.formatDiagnosticsWithColorAndContext([diagnostic], host)); + } else { + return ts.formatDiagnostics(diagnostic, host); + } + }) + .join(''); + } else { + return ''; + } +}; + +interface NgJestCompilation { + warnings: Error[]; + errors: Error[]; +} + +export type DiagnosticsReporter = (diagnostics: Diagnostics) => void; + +export const createDiagnosticsReporter = ( + compilation: NgJestCompilation, + formatter: (diagnostic: Diagnostics[number]) => string, +): DiagnosticsReporter => { + return (diagnostics) => { + for (const diagnostic of diagnostics) { + const text = formatter(diagnostic); + if (diagnostic.category === ts.DiagnosticCategory.Error) { + addError(compilation, text); + } else { + addWarning(compilation, text); + } + } + }; +}; + +const addWarning = (compilation: NgJestCompilation, message: string): void => { + compilation.warnings.push(new Error(message)); +}; + +const addError = (compilation: NgJestCompilation, message: string): void => { + compilation.errors.push(new Error(message)); +}; diff --git a/src/compiler/ng-jest-preprocessor.ts b/src/compiler/ng-jest-preprocessor.ts new file mode 100644 index 0000000000..570600bc5b --- /dev/null +++ b/src/compiler/ng-jest-preprocessor.ts @@ -0,0 +1,214 @@ +import fs from 'fs'; +import path from 'path'; + +import type { ParsedConfiguration } from '@angular/compiler-cli'; +import type { TransformedSource } from '@jest/transform'; +import { type TsCompilerInstance, stringify } from 'ts-jest'; +import { updateOutput } from 'ts-jest/dist/legacy/compiler/compiler-utils'; +import { factory as hoistJest } from 'ts-jest/dist/transformers/hoist-jest'; +import ts from 'typescript'; + +import { constructorParametersDownlevelTransform } from '../transformers/downlevel-ctor'; +import { replaceResourceTransformer } from '../transformers/replace-resources'; +import { ngJestLogger } from '../utils/logger'; + +import { SourceFileCache } from './cache'; +import { augmentHostWithCaching, augmentHostWithDependencyCollection } from './host'; +import { createDiagnosticsReporter, type DiagnosticsReporter, formatDiagnostics } from './ng-jest-diagnostics'; +import { normalizePath } from './paths'; + +type NgJestParsedConfig = Omit; + +const calcProjectFileAndBasePath = (project: string): { projectFile: string; basePath: string } => { + const absProject = path.resolve(project); + const projectIsDir = fs.lstatSync(absProject).isDirectory(); + const projectFile = projectIsDir ? path.join(absProject, 'tsconfig.json') : absProject; + const projectDir = projectIsDir ? absProject : path.dirname(absProject); + const basePath = path.resolve(projectDir); + + return { projectFile, basePath }; +}; + +const readConfiguration = (tsconfig?: string): NgJestParsedConfig => { + try { + const readConfigFile = (configFile: string) => + ts.readConfigFile(configFile, (file) => fs.readFileSync(path.resolve(file), 'utf-8')); + + const tsconfigPath = typeof tsconfig === 'string' ? tsconfig.replace('', '.') : process.cwd(); + const { projectFile, basePath } = calcProjectFileAndBasePath(tsconfigPath); + const configFileName = path.resolve(path.normalize(process.cwd()), projectFile); + const { config, error } = readConfigFile(projectFile); + if (error) { + return { + project: tsconfigPath, + errors: [error], + rootNames: [], + options: {}, + }; + } + + const existingCompilerOptions: ts.CompilerOptions = { + genDir: basePath, + basePath, + }; + const { + options, + errors, + fileNames: rootNames, + projectReferences, + } = ts.parseJsonConfigFileContent(config, ts.sys, basePath, existingCompilerOptions, configFileName); + + // Coerce to boolean as `enableIvy` can be `ngtsc|true|false|undefined` here. + options.enableIvy = !!(options.enableIvy ?? true); + options.noEmitOnError = false; + options.outDir = undefined; + options.suppressOutputPathCheck = true; + options.inlineSources = options.sourceMap; + options.inlineSourceMap = false; + options.mapRoot = undefined; + options.sourceRoot = undefined; + options.allowEmptyCodegenFiles = false; + options.annotationsAs = 'decorators'; + options.enableResourceInlining = false; + options.module = options.module ?? ts.ModuleKind.CommonJS; + options.target = options.target ?? ts.ScriptTarget?.ES2015; + + return { project: projectFile, rootNames, projectReferences, options, errors }; + } catch (e) { + const errors: ts.Diagnostic[] = [ + { + category: ts.DiagnosticCategory.Error, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messageText: (e as any).stack, + file: undefined, + start: undefined, + length: undefined, + source: 'angular', + code: 500, + }, + ]; + + return { project: '', errors, rootNames: [], options: {} }; + } +}; + +const createJitTransformers = (builder: ts.BuilderProgram): ts.CustomTransformers => { + const getTypeChecker = () => builder.getProgram().getTypeChecker(); + + return { + before: [ + replaceResourceTransformer(getTypeChecker), + constructorParametersDownlevelTransform(builder.getProgram()), + hoistJest({ + program: () => builder.getProgram(), + configSet: { logger: ngJestLogger, compilerModule: ts }, + } as unknown as TsCompilerInstance), + ], + }; +}; + +const getDiagnostics = (builder: ts.BuilderProgram, diagnosticsReporter: DiagnosticsReporter): void => { + const diagnostics = [ + ...builder.getOptionsDiagnostics(), + ...builder.getGlobalDiagnostics(), + ...builder.getSyntacticDiagnostics(), + // Gather incremental semantic diagnostics + ...builder.getSemanticDiagnostics(), + ]; + diagnosticsReporter(diagnostics); +}; + +export const getDiskPrecompiledOutputPath = (): string => path.join(__dirname, '../../.precompiledOutput'); + +class NgJestPreprocessor { + private readonly sourceFileCache = new SourceFileCache(); + private readonly fileDependencies = new Map>(); + private oldBuilderProgram: ts.BuilderProgram | undefined; + + performCompile(tsconfigPath: string | undefined): void { + const compiledOutput: Record = Object.create(null); + const diagnosticWarnings: Error[] = []; + const diagnosticErrors: Error[] = []; + const emittedFiles: Record = Object.create(null); + const { options: compilerOptions, rootNames, errors, projectReferences } = readConfiguration(tsconfigPath); + // Create diagnostics reporter and report configuration file errors + const diagnosticsReporter = createDiagnosticsReporter( + { + warnings: diagnosticWarnings, + errors: diagnosticErrors, + }, + (diagnostic) => formatDiagnostics([diagnostic]), + ); + diagnosticsReporter(errors); + + process.stdout.write('Creating compiler (phase: setup).\n'); + + const host = ts.createIncrementalCompilerHost(compilerOptions, ts.sys); + // Setup source file caching and reuse cache from previous compilation if present + augmentHostWithCaching(host, this.sourceFileCache); + const moduleResolutionCache = ts.createModuleResolutionCache( + host.getCurrentDirectory(), + host.getCanonicalFileName.bind(host), + compilerOptions, + ); + + // Setup source file dependency collection + augmentHostWithDependencyCollection(host, this.fileDependencies, moduleResolutionCache); + + // When not in watch mode, the startup cost of the incremental analysis can be avoided by + // using an abstract builder that only wraps a TypeScript program. + const builderProgram = ts.createAbstractBuilder( + rootNames, + compilerOptions, + host, + this.oldBuilderProgram, + errors, + projectReferences, + ); + this.oldBuilderProgram = builderProgram; + getDiagnostics(builderProgram, diagnosticsReporter); + + const transformers = createJitTransformers(builderProgram); + + process.stdout.write('Compiler creation complete.\n'); + + process.stdout.write('Start compiling test sources.\n'); + + builderProgram.emit( + undefined, + (fileName, data) => { + const normalizedFileName = normalizePath(fileName); + const tsVersionFileName = normalizedFileName.replace(fileName.endsWith('.js') ? '.js' : '.js.map', '.ts'); + const cachedEmittedFile = emittedFiles[tsVersionFileName] ?? Object.create(null); + if (fileName.endsWith('.map')) { + emittedFiles[tsVersionFileName] = { + ...cachedEmittedFile, + map: data, + }; + + return; + } else if (fileName.endsWith('.js')) { + emittedFiles[tsVersionFileName] = { + code: data, + }; + compiledOutput[tsVersionFileName] = updateOutput( + data, + tsVersionFileName, + emittedFiles[tsVersionFileName].map as string | undefined, + ); + } + }, + undefined, + undefined, + transformers, + ); + + process.stdout.write('Test sources compilation complete.\n'); + + ts.sys.writeFile(getDiskPrecompiledOutputPath(), stringify(compiledOutput)); + } +} + +const ngJestPreprocessor = new NgJestPreprocessor(); + +export { ngJestPreprocessor }; diff --git a/src/compiler/paths.ts b/src/compiler/paths.ts new file mode 100644 index 0000000000..193a645996 --- /dev/null +++ b/src/compiler/paths.ts @@ -0,0 +1,14 @@ +import nodePath from 'path'; + +const normalizationCache = new Map(); + +export const normalizePath = (path: string): string => { + let result = normalizationCache.get(path); + + if (result === undefined) { + result = nodePath.win32.normalize(path).replace(/\\/g, nodePath.posix.sep); + normalizationCache.set(path, result); + } + + return result; +}; diff --git a/src/config/global-setup.spec.ts b/src/config/global-setup.spec.ts index 9c54f24b32..e780e8d624 100644 --- a/src/config/global-setup.spec.ts +++ b/src/config/global-setup.spec.ts @@ -1,36 +1,103 @@ +import { jest } from '@jest/globals'; + +import { ngJestPreprocessor } from '../compiler/ng-jest-preprocessor'; +import { runNgccJestProcessor } from '../utils/ngcc-jest-processor'; + import globalSetup from './global-setup'; jest.mock('../utils/ngcc-jest-processor', () => { return { - runNgccJestProcessor() { - console.log('Mock ngcc jest processor'); + runNgccJestProcessor: jest.fn(), + }; +}); +jest.mock('../compiler/ng-jest-preprocessor', () => { + return { + ngJestPreprocessor: { + performCompile: jest.fn().mockReturnValue({ + foo: 'bar', + }), }, }; }); +const mockedRunNgccJestProcessor = jest.mocked(runNgccJestProcessor); +const mockedNgJestPreprocessor = jest.mocked(ngJestPreprocessor); describe('global-setup', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test.each([false, undefined])( + 'should not skip ngcc-jest-processor with `skipNgcc: %s` option in `ngJest` config', + async (skipNgcc) => { + globalThis.ngJest = { + skipNgcc, + tsconfig: 'foo.json', + }; + + await globalSetup(Object.create(null)); + + expect(mockedRunNgccJestProcessor).toHaveBeenCalledWith(globalThis.ngJest.tsconfig); + }, + ); + test('should skip ngcc-jest-processor with `skipNgcc: true` option in `ngJest` config', async () => { - console.log = jest.fn(); globalThis.ngJest = { skipNgcc: true, }; - await globalSetup(); + await globalSetup(Object.create(null)); - expect(console.log).not.toHaveBeenCalled(); + expect(mockedRunNgccJestProcessor).not.toHaveBeenCalled(); + }); + + test.each([undefined, 'foo.json'])( + 'should run ngJestPreprocessor with `experimentalPrecompilation: true` option in `ngJest` config in non-watch mode', + async (tsconfig) => { + globalThis.ngJest = { + experimentalPrecompilation: true, + tsconfig, + }; + + await globalSetup(Object.create(null)); + + expect(mockedNgJestPreprocessor.performCompile).toHaveBeenCalledWith(globalThis.ngJest.tsconfig); + expect(process.env.precompiledCount).toBeUndefined(); + }, + ); + + test('should run ngJestPreprocessor with `experimentalPrecompilation: true` option in `ngJest` config in watch mode', async () => { + globalThis.ngJest = { + experimentalPrecompilation: true, + tsconfig: 'foo.json', + }; + + await globalSetup({ + ...Object.create(null), + watch: true, + }); + + expect(mockedNgJestPreprocessor.performCompile).toHaveBeenCalledWith(globalThis.ngJest.tsconfig); + expect(process.env.precompiledCount).toBe('0'); + + await globalSetup({ + ...Object.create(null), + watch: true, + }); + + expect(process.env.precompiledCount).toBe('1'); }); test.each([false, undefined])( - 'should not skip ngcc-jest-processor with `skipNgcc: %s` option in `ngJest` config', - async (skipNgcc) => { - console.log = jest.fn(); + 'should not run ngJestPreprocessor with `experimentalPrecompilation: true` option in `ngJest` config', + async (experimentalPrecompilation) => { globalThis.ngJest = { - skipNgcc, + experimentalPrecompilation, }; - await globalSetup(); + await globalSetup(Object.create(null)); - expect(console.log).toHaveBeenCalled(); + expect(mockedNgJestPreprocessor.performCompile).not.toHaveBeenCalled(); }, ); }); diff --git a/src/config/global-setup.ts b/src/config/global-setup.ts index 9a904b7faa..8bf090568a 100644 --- a/src/config/global-setup.ts +++ b/src/config/global-setup.ts @@ -1,9 +1,21 @@ +import type { Config } from '@jest/types'; + +import { ngJestPreprocessor } from '../compiler/ng-jest-preprocessor'; import { runNgccJestProcessor } from '../utils/ngcc-jest-processor'; -export = async () => { +let precompiledCount: number | undefined; + +export = async (globalConfig: Config.GlobalConfig) => { const ngJestConfig = globalThis.ngJest; const tsconfig = ngJestConfig?.tsconfig; if (!ngJestConfig?.skipNgcc) { runNgccJestProcessor(tsconfig); } + if (ngJestConfig.experimentalPrecompilation) { + ngJestPreprocessor.performCompile(tsconfig); + if (globalConfig.watch) { + precompiledCount = precompiledCount === undefined ? 0 : precompiledCount + 1; + process.env.precompiledCount = precompiledCount.toString(); + } + } }; diff --git a/src/constants.ts b/src/constants.ts index bf4036663f..29b66dfe85 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,3 +10,5 @@ export const TEMPLATE = 'template'; export const REQUIRE = 'require'; /** Angular component decorator name */ export const COMPONENT = 'Component'; + +export const REGISTER_INSTANCE = Symbol.for('ng-jest.register.instance'); diff --git a/src/global.d.ts b/src/global.d.ts index 39fb5a07e9..7490b19351 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -4,7 +4,9 @@ declare global { var ngJest: { skipNgcc?: boolean; tsconfig?: string; + experimentalPrecompilation?: boolean; }; } + export {} diff --git a/src/ng-jest-transformer.ts b/src/ng-jest-transformer.ts index 5391a413bf..5e99510ff5 100644 --- a/src/ng-jest-transformer.ts +++ b/src/ng-jest-transformer.ts @@ -1,36 +1,43 @@ import { spawnSync } from 'child_process'; +import fs from 'fs'; import path from 'path'; -import type { TransformedSource } from '@jest/transform'; -import { LogContexts, LogLevels, type Logger, createLogger } from 'bs-logger'; +import type { TransformedSource, TransformOptions } from '@jest/transform'; +import type { ProjectConfigTsJest } from 'ts-jest'; +import { parse } from 'ts-jest'; import { ConfigSet } from 'ts-jest/dist/legacy/config/config-set'; import { TsJestTransformer } from 'ts-jest/dist/legacy/ts-jest-transformer'; -import type { ProjectConfigTsJest, TransformOptionsTsJest } from 'ts-jest/dist/types'; import { NgJestCompiler } from './compiler/ng-jest-compiler'; +import { getDiskPrecompiledOutputPath } from './compiler/ng-jest-preprocessor'; import { NgJestConfig } from './config/ng-jest-config'; +import type { TransformOptionsNgJest } from './types'; +import { ngJestLogger } from './utils/logger'; + +type PrecompiledOutput = Record | undefined; // Cache the result between multiple transformer instances // to avoid spawning multiple processes (which can have a major // performance impact when used with multiple projects). let useNativeEsbuild: boolean | undefined; +let diskPrecompiledOutput: PrecompiledOutput; +const getDiskCompiledOutput = (diskCompiledOutputPath: string): PrecompiledOutput => { + try { + const diskCompiledOutput = fs.readFileSync(diskCompiledOutputPath, 'utf-8'); + if (diskCompiledOutput) { + return parse(diskCompiledOutput); + } + } catch {} + + return undefined; +}; export class NgJestTransformer extends TsJestTransformer { - #ngJestLogger: Logger; - #esbuildImpl: typeof import('esbuild'); + private readonly ngJestLogger = ngJestLogger; + private readonly esbuildImpl: typeof import('esbuild'); constructor() { super(); - this.#ngJestLogger = createLogger({ - context: { - [LogContexts.package]: 'jest-preset-angular', - [LogContexts.logLevel]: LogLevels.trace, - // eslint-disable-next-line @typescript-eslint/no-var-requires - version: require('../package.json').version, - }, - targets: process.env.NG_JEST_LOG ?? undefined, - }); - if (useNativeEsbuild === undefined) { try { const esbuildCheckPath = require.resolve('@angular-devkit/build-angular/esbuild-check.js'); @@ -40,8 +47,17 @@ export class NgJestTransformer extends TsJestTransformer { useNativeEsbuild = false; } } + this.esbuildImpl = useNativeEsbuild ? require('esbuild') : require('esbuild-wasm'); - this.#esbuildImpl = useNativeEsbuild ? require('esbuild') : require('esbuild-wasm'); + const diskCompiledOutputPath = getDiskPrecompiledOutputPath(); + if (!diskPrecompiledOutput) { + diskPrecompiledOutput = getDiskCompiledOutput(diskCompiledOutputPath); + } else { + const precompiledCount = process.env.precompiledCount ? +process.env.precompiledCount : 0; + if (precompiledCount > 0) { + diskPrecompiledOutput = getDiskCompiledOutput(diskCompiledOutputPath); + } + } } protected _createConfigSet(config: ProjectConfigTsJest | undefined): ConfigSet { @@ -52,7 +68,7 @@ export class NgJestTransformer extends TsJestTransformer { this._compiler = new NgJestCompiler(configSet, cacheFS); } - process(fileContent: string, filePath: string, transformOptions: TransformOptionsTsJest): TransformedSource { + process(fileContent: string, filePath: string, transformOptions: TransformOptionsNgJest): TransformedSource { // @ts-expect-error we are accessing the private cache to avoid creating new objects all the time const configSet = super._configsFor(transformOptions); /** @@ -65,10 +81,10 @@ export class NgJestTransformer extends TsJestTransformer { path.extname(filePath) === '.mjs' || (/node_modules\/(.*.js$)/.test(filePath.replace(/\\/g, '/')) && !filePath.includes('tslib')) ) { - this.#ngJestLogger.debug({ filePath }, 'process with esbuild'); + this.ngJestLogger.debug({ filePath }, 'process with esbuild'); const compilerOpts = configSet.parsedTsConfig.options; - const { code, map } = this.#esbuildImpl.transformSync(fileContent, { + const { code, map } = this.esbuildImpl.transformSync(fileContent, { loader: 'js', format: transformOptions.supportsStaticESM && configSet.useESM ? 'esm' : 'cjs', target: compilerOpts.target === configSet.compilerModule.ScriptTarget.ES2015 ? 'es2015' : 'es2016', @@ -83,7 +99,29 @@ export class NgJestTransformer extends TsJestTransformer { map, }; } else { - return super.process(fileContent, filePath, transformOptions); + const filePrecompiledOutput = diskPrecompiledOutput && diskPrecompiledOutput[filePath]; + + return filePrecompiledOutput + ? { + code: filePrecompiledOutput, + } + : super.process(fileContent, filePath, transformOptions as TransformOptions); } } + + processAsync( + sourceText: string, + filePath: string, + transformOptions: TransformOptionsNgJest, + ): Promise { + this.ngJestLogger.debug({ fileName: filePath, transformOptions }, 'processing', filePath); + + const filePrecompiledOutput = diskPrecompiledOutput && diskPrecompiledOutput[filePath]; + + return filePrecompiledOutput + ? Promise.resolve({ + code: filePrecompiledOutput, + }) + : super.processAsync(sourceText, filePath, transformOptions as TransformOptions); + } } diff --git a/src/transformers/replace-resources.ts b/src/transformers/replace-resources.ts index 22c57b6c99..d4cfb2ba3f 100644 --- a/src/transformers/replace-resources.ts +++ b/src/transformers/replace-resources.ts @@ -11,41 +11,10 @@ import ts from 'typescript'; import { STYLES, STYLE_URLS, TEMPLATE_URL, TEMPLATE, REQUIRE, COMPONENT } from '../constants'; const shouldTransform = (fileName: string) => !fileName.endsWith('.ngfactory.ts') && !fileName.endsWith('.ngstyle.ts'); -/** - * Source https://github.com/angular/angular-cli/blob/master/packages/ngtools/webpack/src/transformers/replace_resources.ts - * - * Check `@Component` to do following things: - * - Replace `templateUrl` path with `require` for `CommonJS` or a constant with `import` for `ESM` - * - Remove `styles` and `styleUrls` because we don't test css - * - * @example - * - * Given the input - * @Component({ - * selector: 'foo', - * templateUrl: './foo.component.html`, - * styleUrls: ['./foo.component.scss'], - * styles: [`h1 { font-size: 16px }`], - * }) - * - * Produced the output for `CommonJS` - * @Component({ - * selector: 'foo', - * templateUrl: require('./foo.component.html'), - * }) - * - * or for `ESM` - * import __NG_CLI_RESOURCE__0 from './foo.component.html'; - * - * @Component({ - * selector: 'foo', - * templateUrl: __NG_CLI_RESOURCE__0, - * }) - */ -export function replaceResources({ program }: TsCompilerInstance): ts.TransformerFactory { + +export const replaceResourceTransformer = (getTypeChecker: () => ts.TypeChecker) => { return (context: ts.TransformationContext) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const typeChecker = program!.getTypeChecker(); + const typeChecker = getTypeChecker(); const resourceImportDeclarations: ts.ImportDeclaration[] = []; const moduleKind = context.getCompilerOptions().module; const visitNode: ts.Visitor = (node: ts.Node) => { @@ -90,6 +59,41 @@ export function replaceResources({ program }: TsCompilerInstance): ts.Transforme return updatedSourceFile; }; }; +}; +/** + * Source https://github.com/angular/angular-cli/blob/master/packages/ngtools/webpack/src/transformers/replace_resources.ts + * + * Check `@Component` to do following things: + * - Replace `templateUrl` path with `require` for `CommonJS` or a constant with `import` for `ESM` + * - Remove `styles` and `styleUrls` because we don't test css + * + * @example + * + * Given the input + * @Component({ + * selector: 'foo', + * templateUrl: './foo.component.html`, + * styleUrls: ['./foo.component.scss'], + * styles: [`h1 { font-size: 16px }`], + * }) + * + * Produced the output for `CommonJS` + * @Component({ + * selector: 'foo', + * templateUrl: require('./foo.component.html'), + * }) + * + * or for `ESM` + * import __NG_CLI_RESOURCE__0 from './foo.component.html'; + * + * @Component({ + * selector: 'foo', + * templateUrl: __NG_CLI_RESOURCE__0, + * }) + */ +export function replaceResources({ program }: TsCompilerInstance): ts.TransformerFactory { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return replaceResourceTransformer(program!.getTypeChecker); } function visitDecorator( diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000000..67f369fd7d --- /dev/null +++ b/src/types.ts @@ -0,0 +1,14 @@ +import type { TransformOptionsTsJest, TsJestGlobalOptions } from 'ts-jest/dist/types'; + +export interface NgJestGlobalOptions { + precompiledOutput: Record; +} + +export interface TransformOptionsNgJest extends Omit { + config: { + globals: { + 'ts-jest': TsJestGlobalOptions; + ngJest?: NgJestGlobalOptions; + }; + }; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000000..12c87e9541 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,13 @@ +import { createLogger, LogContexts, LogLevels } from 'bs-logger'; + +const ngJestLogger = createLogger({ + context: { + [LogContexts.package]: 'jest-preset-angular', + [LogContexts.logLevel]: LogLevels.trace, + // eslint-disable-next-line @typescript-eslint/no-var-requires + version: require('../../package.json').version, + }, + targets: process.env.NG_JEST_LOG ?? undefined, +}); + +export { ngJestLogger };