diff --git a/src/compiler/ng-jest-compiler.spec.ts b/src/compiler/ng-jest-compiler.spec.ts index 01ae74c100..07a7704665 100644 --- a/src/compiler/ng-jest-compiler.spec.ts +++ b/src/compiler/ng-jest-compiler.spec.ts @@ -1,34 +1,479 @@ -import { ConfigSet } from 'ts-jest/dist/legacy/config/config-set'; +import type { Config } from '@jest/types'; +import type { RawCompilerOptions } from 'ts-jest'; import { NgJestCompiler } from './ng-jest-compiler'; +import { NgJestConfig } from '../config/ng-jest-config'; + +function dedent(strings: TemplateStringsArray, ...values: unknown[]) { + let joinedString = ''; + for (let i = 0; i < values.length; i++) { + joinedString += `${strings[i]}${values[i]}`; + } + joinedString += strings[strings.length - 1]; + + return omitLeadingWhitespace(joinedString); +} + +/** Omits the leading whitespace for each line of the given text. */ +function omitLeadingWhitespace(text: string): string { + return text.replace(/^\s+/gm, ''); +} + +function transformCjs(contents: string, compilerOptions: RawCompilerOptions = {}) { + const ngJestConfig = new NgJestConfig({ + cwd: process.cwd(), + extensionsToTreatAsEsm: [], + testMatch: [], + testRegex: [], + globals: { + 'ts-jest': { + isolatedModules: true, + tsconfig: { + sourceMap: false, + module: 'CommonJS', + target: 'ES2017', + lib: ['dom', 'es2015'], + importHelpers: true, + experimentalDecorators: true, + emitDecoratorMetadata: false, + ...compilerOptions, + }, + }, + }, + } as unknown as Config.ProjectConfig); + const compiler = new NgJestCompiler(ngJestConfig, new Map()); + const { code, diagnostics = [] } = compiler.getCompiledOutput(contents, __filename, { + watchMode: false, + depGraphs: new Map(), + supportsStaticESM: false, + }); + + return { + code: omitLeadingWhitespace(code), + diagnostics, + }; +} describe('NgJestCompiler', () => { - test('should transform codes using hoisting, replace resources and downlevel ctor transformers', () => { - const ngJestConfig = new ConfigSet({ - cwd: process.cwd(), - extensionsToTreatAsEsm: [], - testMatch: [], - testRegex: [], - transform: { - '^.+\\.(ts|js|mjs|html|svg)$': [ - 'ts-jest', - { - isolatedModules: true, - tsconfig: { - sourceMap: false, - }, - }, - ], + describe('_transpileOutput', () => { + it('should downlevel decorators for @Injectable decorated class', () => { + const { code, diagnostics = [] } = transformCjs(` + import {Injectable} from '@angular/core'; + + export class ClassInject {}; + + @Injectable() + export class MyService { + constructor(v: ClassInject) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + MyService.ctorParameters = () => [ + { type: ClassInject } + ]; + exports.MyService = MyService = tslib_1.__decorate([ + (0, core_1.Injectable)() + ], MyService); + `); + }); + + it('should downlevel decorators for @Directive decorated class', () => { + const { code, diagnostics } = transformCjs(` + import {Directive} from '@angular/core'; + + export class ClassInject {}; + + @Directive() + export class MyDir { + constructor(v: ClassInject) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + MyDir.ctorParameters = () => [ + { type: ClassInject } + ]; + exports.MyDir = MyDir = tslib_1.__decorate([ + (0, core_1.Directive)() + ], MyDir); + `); + }); + + it('should downlevel decorators for @Component decorated class', () => { + const { code, diagnostics } = transformCjs(` + import {Component} from '@angular/core'; + + export class ClassInject {}; + + @Component({template: 'hello'}) + export class MyComp { + constructor(v: ClassInject) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + MyComp.ctorParameters = () => [ + { type: ClassInject } + ]; + exports.MyComp = MyComp = tslib_1.__decorate([ + (0, core_1.Component)({ template: 'hello' }) + ], MyComp); + `); + }); + + it('should downlevel decorators for @Pipe decorated class', () => { + const { code, diagnostics } = transformCjs(` + import {Pipe} from '@angular/core'; + + export class ClassInject {}; + + @Pipe({selector: 'hello'}) + export class MyPipe { + constructor(v: ClassInject) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + MyPipe.ctorParameters = () => [ + { type: ClassInject } + ]; + exports.MyPipe = MyPipe = tslib_1.__decorate([ + (0, core_1.Pipe)({ selector: 'hello' }) + ], MyPipe); + `); + }); + + it('should not downlevel non-Angular class decorators', () => { + const { code, diagnostics } = transformCjs(` + @SomeUnknownDecorator() + export class MyClass {} + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + exports.MyClass = MyClass = tslib_1.__decorate([ + SomeUnknownDecorator() + ], MyClass); + `); + expect(code).not.toContain('MyClass.decorators'); + }); + + it('should not downlevel non-Angular class decorators generated by a builder', () => { + const { code, diagnostics } = transformCjs(` + @DecoratorBuilder().customClassDecorator + export class MyClass {} + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + exports.MyClass = MyClass = tslib_1.__decorate([ + DecoratorBuilder().customClassDecorator + ], MyClass); + `); + expect(code).not.toContain('MyClass.decorators'); + }); + + it('should downlevel Angular-decorated class member', () => { + const { code, diagnostics } = transformCjs(` + import {Input} from '@angular/core'; + + export class MyDir { + @Input() disabled: boolean = false; + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + MyDir.propDecorators = { + disabled: [{ type: core_1.Input }] + }; + `); + expect(code).not.toContain('tslib'); + }); + + // Regression test for a scenario where previously the class within a constructor body + // would be processed twice, where the downleveled class is revisited accidentally and + // caused invalid generation of the `ctorParameters` static class member. + it('should not duplicate constructor parameters for classes part of constructor body', () => { + // Note: the bug with duplicated/invalid generation only surfaces when the actual class + // decorators are preserved and emitted by TypeScript itself. This setting is also + // disabled within the CLI. + const { code, diagnostics } = transformCjs(` + import {Injectable} from '@angular/core'; + + export class ZoneToken {} + + @Injectable() + export class Wrapper { + constructor(y: ZoneToken) { + @Injectable() + class ShouldBeProcessed { + constructor(x: ZoneToken) {} + } + } + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + let Wrapper = class Wrapper { + constructor(y) { + let ShouldBeProcessed = class ShouldBeProcessed { + constructor(x) { } + }; + ShouldBeProcessed.ctorParameters = () => [ + { type: ZoneToken } + ]; + ShouldBeProcessed = tslib_1.__decorate([ + (0, core_1.Injectable)() + ], ShouldBeProcessed); + } + }; + exports.Wrapper = Wrapper; + `); + }); + + // Angular is not concerned with type information for decorated class members. Instead, + // the type is omitted. This also helps with server side rendering as DOM globals which + // are used as types, do not load at runtime. https://github.com/angular/angular/issues/30586. + it('should downlevel Angular-decorated class member but not preserve type', () => { + const { code, diagnostics } = transformCjs(` + import {Input} from '@angular/core'; + import {MyOtherClass} from './other-file'; + + export class MyDir { + @Input() trigger: HTMLElement; + @Input() fromOtherFile: MyOtherClass; + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + MyDir.propDecorators = { + trigger: [{ type: core_1.Input }], + fromOtherFile: [{ type: core_1.Input }] + }; + `); + expect(code).not.toContain('HTMLElement'); + expect(code).not.toContain('MyOtherClass'); + }); + + it('should capture constructor type metadata with `emitDecoratorMetadata` disabled', () => { + const { code, diagnostics } = transformCjs(` + import {Directive} from '@angular/core'; + import {MyOtherClass} from './other-file'; + + @Directive() + export class MyDir { + constructor(other: MyOtherClass) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain('const other_file_1 = require("./other-file");'); + expect(code).toContain(dedent` + MyDir.ctorParameters = () => [ + { type: other_file_1.MyOtherClass } + ]; + exports.MyDir = MyDir = tslib_1.__decorate([ + (0, core_1.Directive)() + ], MyDir); + `); + }); + + it('should properly serialize constructor parameter with local qualified name type', () => { + const { code, diagnostics } = transformCjs(` + import {Directive} from '@angular/core'; + + namespace other { + export class OtherClass {} + }; + + @Directive() + export class MyDir { + constructor(other: other.OtherClass) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain('var other;'); + expect(code).toContain(dedent` + MyDir.ctorParameters = () => [ + { type: other.OtherClass } + ]; + exports.MyDir = MyDir = tslib_1.__decorate([ + (0, core_1.Directive)() + ], MyDir); + `); + }); + + it('should properly downlevel constructor parameter decorators with built-in lib types', () => { + const { code, diagnostics } = transformCjs(` + import {Inject, Directive, DOCUMENT} from '@angular/core'; + + @Directive() + export class MyDir { + constructor(@Inject(DOCUMENT) document: Document) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + MyDir.ctorParameters = () => [ + { type: Document, decorators: [{ type: core_1.Inject, args: [core_1.DOCUMENT,] }] } + ]; + exports.MyDir = MyDir = tslib_1.__decorate([ + (0, core_1.Directive)() + ], MyDir); + `); + }); + + it('should properly downlevel constructor parameters with union type', () => { + const { code, diagnostics } = transformCjs(` + import {Optional, Directive, NgZone} from '@angular/core'; + + @Directive() + export class MyDir { + constructor(@Optional() ngZone: NgZone|null) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + MyDir.ctorParameters = () => [ + { type: core_1.NgZone, decorators: [{ type: core_1.Optional }] } + ]; + exports.MyDir = MyDir = tslib_1.__decorate([ + (0, core_1.Directive)() + ], MyDir); + `); + }); + + it( + 'should not retain unused type imports due to decorator downleveling with ' + + '`emitDecoratorMetadata` enabled.', + () => { + const { code, diagnostics } = transformCjs( + ` + import {Directive, Inject} from '@angular/core'; + import {ErrorHandler, ClassInject} from './external'; + + export class MyDir { + private _errorHandler: ErrorHandler; + constructor(@Inject(ClassInject) i: ClassInject) {} + } + `, + { module: 'ES2015', emitDecoratorMetadata: true }, + ); + + expect(diagnostics.length).toBe(0); + expect(code).not.toContain('Directive'); + expect(code).not.toContain('ErrorHandler'); + }, + ); + + it( + 'should not retain unused type imports due to decorator downleveling with ' + + '`emitDecoratorMetadata` disabled', + () => { + const { code, diagnostics } = transformCjs( + ` + import {Directive, Inject} from '@angular/core'; + import {ErrorHandler, ClassInject} from './external'; + + export class MyDir { + private _errorHandler: ErrorHandler; + constructor(@Inject(ClassInject) i: ClassInject) {} + } + `, + { module: 'ES2015', emitDecoratorMetadata: false }, + ); + + expect(diagnostics.length).toBe(0); + expect(code).not.toContain('Directive'); + expect(code).not.toContain('ErrorHandler'); }, - } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - const compiler = new NgJestCompiler(ngJestConfig, new Map()); - compiler.program = { - // @ts-expect-error testing purpose - // eslint-disable-next-line @typescript-eslint/no-empty-function - getTypeChecker: () => {}, - }; - - // @ts-expect-error `_makeTransformers` is a private method - expect(compiler._makeTransformers(compiler.configSet.resolvedTransformers).before.length).toEqual(3); + ); + + it('should not generate invalid reference due to conflicting parameter name', () => { + const { code, diagnostics } = transformCjs( + ` + import {Directive} from '@angular/core'; + import {Dep} from './external'; + + @Directive() + export class MyDir { + constructor(Dep: Dep) { + Dep.greet(); + } + } + `, + { emitDecoratorMetadata: false }, + ); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(`external_1 = require("./external");`); + expect(code).toContain(dedent` + MyDir.ctorParameters = () => [ + { type: external_1.Dep } + ]; + exports.MyDir = MyDir = tslib_1.__decorate([ + (0, core_1.Directive)() + ], MyDir); + `); + }); + + it('should be able to serialize circular constructor parameter type', () => { + const { code, diagnostics } = transformCjs(` + import {Directive, Optional, Inject, SkipSelf} from '@angular/core'; + + @Directive() + export class MyDir { + constructor(@Optional() @SkipSelf() @Inject(MyDir) parentDir: MyDir|null) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + let MyDir = class MyDir { + constructor(parentDir) { } + }; + exports.MyDir = MyDir; + MyDir.ctorParameters = () => [ + { type: MyDir, decorators: [{ type: core_1.Optional }, { type: core_1.SkipSelf }, { type: core_1.Inject, args: [MyDir,] }] } + ]; + exports.MyDir = MyDir = tslib_1.__decorate([ + (0, core_1.Directive)() + ], MyDir); + `); + }); + + it('should capture a non-const enum used as a constructor type', () => { + const { code, diagnostics } = transformCjs(` + import {Component} from '@angular/core'; + + export enum Values {A, B}; + + @Component({template: 'hello'}) + export class MyComp { + constructor(v: Values) {} + } + `); + + expect(diagnostics.length).toBe(0); + expect(code).toContain(dedent` + MyComp.ctorParameters = () => [ + { type: Values } + ]; + exports.MyComp = MyComp = tslib_1.__decorate([ + (0, core_1.Component)({ template: 'hello' }) + ], MyComp); + `); + }); }); }); diff --git a/src/compiler/ng-jest-compiler.ts b/src/compiler/ng-jest-compiler.ts index 21aa715dcf..dbd111e2a9 100644 --- a/src/compiler/ng-jest-compiler.ts +++ b/src/compiler/ng-jest-compiler.ts @@ -1,29 +1,49 @@ -import os from 'os'; -import path from 'path'; +import os from 'node:os'; +import path from 'node:path'; import { type TsJestAstTransformer, TsCompiler, type ConfigSet } from 'ts-jest'; -import type * as ts from 'typescript'; +import type ts from 'typescript'; import { angularJitApplicationTransform } from '../transformers/jit_transform'; import { replaceResources } from '../transformers/replace-resources'; export class NgJestCompiler extends TsCompiler { + private readonly _defaultLibDirPath: string; + private readonly _libSourceFileCache = new Map(); + constructor(readonly configSet: ConfigSet, readonly jestCacheFS: Map) { super(configSet, jestCacheFS); this._logger.debug('created NgJestCompiler'); + this._defaultLibDirPath = path.dirname(this._ts.getDefaultLibFilePath(this._compilerOptions)); } /** * Copy from https://github.com/microsoft/TypeScript/blob/master/src/services/transpile.ts * This is required because the exposed function `transpileModule` from TypeScript doesn't allow to access `Program` - * and we need `Program` to be able to use Angular `replace-resources` transformer. + * and we need `Program` to be able to use Angular AST transformers. */ protected _transpileOutput(fileContent: string, filePath: string): ts.TranspileOutput { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const sourceFile = this._ts.createSourceFile(filePath, fileContent, this._compilerOptions.target!); + const scriptTarget = this._compilerOptions.target ?? this._ts.ScriptTarget.Latest; + const sourceFile = this._ts.createSourceFile(filePath, fileContent, scriptTarget); const compilerHost: ts.CompilerHost = { - getSourceFile: (fileName) => (fileName === path.normalize(filePath) ? sourceFile : undefined), + getSourceFile: (fileName) => { + if (fileName === path.normalize(filePath)) { + return sourceFile; + } + + let libSourceFile = this._libSourceFileCache.get(fileName); + if (!libSourceFile) { + const libFilePath = path.join(this._defaultLibDirPath, fileName); + const libFileContent = this._ts.sys.readFile(libFilePath) ?? ''; + if (libFileContent) { + libSourceFile = this._ts.createSourceFile(fileName, libFileContent, scriptTarget); + this._libSourceFileCache.set(fileName, libSourceFile); + } + } + + return libSourceFile; + }, // eslint-disable-next-line @typescript-eslint/no-empty-function writeFile: () => {}, getDefaultLibFileName: () => 'lib.d.ts', @@ -31,7 +51,7 @@ export class NgJestCompiler extends TsCompiler { getCanonicalFileName: (fileName) => fileName, getCurrentDirectory: () => '', getNewLine: () => os.EOL, - fileExists: (fileName): boolean => fileName === filePath, + fileExists: (fileName) => fileName === filePath, readFile: () => '', directoryExists: () => true, getDirectories: () => [],