/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { CustomTransformers, defaultGatherDiagnostics, Program, createCompilerHost, createProgram, } from '../../index'; import {DocEntry} from '../../src/ngtsc/docs'; import * as api from '../../src/transformers/api'; import ts from 'typescript'; import {mainXi18n} from '../../src/extract_i18n'; import {main, mainDiagnosticsForTest, readNgcCommandLineAndConfiguration} from '../../src/main'; import { absoluteFrom, AbsoluteFsPath, FileSystem, getFileSystem, relativeFrom, } from '../../src/ngtsc/file_system'; import {Folder, MockFileSystem} from '../../src/ngtsc/file_system/testing'; import {IndexedComponent} from '../../src/ngtsc/indexer'; import {NgtscProgram} from '../../src/ngtsc/program'; import {DeclarationNode} from '../../src/ngtsc/reflection'; import {NgtscTestCompilerHost} from '../../src/ngtsc/testing'; import {TemplateTypeChecker} from '../../src/ngtsc/typecheck/api'; import {setWrapHostForTest} from '../../src/transformers/compiler_host'; type TsConfigOptionsValue = | string | boolean | number | null | TsConfigOptionsValue[] | {[key: string]: TsConfigOptionsValue}; export type TsConfigOptions = { [key: string]: TsConfigOptionsValue; }; type KnownKeys = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; }; // We don't use ts.CompilerOptions directly since enum-based options // require additional mapping to be JSON-ified. type TsCompilerOptions = Partial< Record, ts.CompilerOptionsValue> >; /** * Manages a temporary testing directory structure and environment for testing ngtsc by feeding it * TypeScript code. */ export class NgtscTestEnvironment { private multiCompileHostExt: MultiCompileHostExt | null = null; private oldProgram: Program | null = null; private changedResources: Set | null = null; private commandLineArgs: string[]; private constructor( private fs: FileSystem, readonly outDir: AbsoluteFsPath, readonly basePath: AbsoluteFsPath, ) { this.commandLineArgs = ['-p', this.basePath]; } /** * Set up a new testing environment. */ static setup(files: Folder = {}, workingDir?: AbsoluteFsPath): NgtscTestEnvironment { const fs = getFileSystem(); if (fs instanceof MockFileSystem) { fs.init(files); } const host = new AugmentedCompilerHost(fs); setWrapHostForTest(makeWrapHost(host)); workingDir = workingDir ?? absoluteFrom('/'); const env = new NgtscTestEnvironment(fs, fs.resolve('/built'), workingDir); fs.chdir(workingDir); env.write( absoluteFrom('/tsconfig-base.json'), `{ "compilerOptions": { "emitDecoratorMetadata": false, "strictPropertyInitialization": false, "experimentalDecorators": true, "skipLibCheck": true, "noImplicitAny": true, "noEmitOnError": true, "strictNullChecks": true, "outDir": "built", "rootDir": ".", "allowJs": true, "declaration": true, "target": "es2015", "newLine": "lf", "module": "es2015", "moduleResolution": "bundler", "lib": ["es2015", "dom"], "typeRoots": ["node_modules/@types"] }, "exclude": [ "built" ] }`, ); return env; } assertExists(fileName: string) { if (!this.fs.exists(this.fs.resolve(this.outDir, fileName))) { throw new Error(`Expected ${fileName} to be emitted (outDir: ${this.outDir})`); } } assertDoesNotExist(fileName: string) { if (this.fs.exists(this.fs.resolve(this.outDir, fileName))) { throw new Error(`Did not expect ${fileName} to be emitted (outDir: ${this.outDir})`); } } getContents(fileName: string): string { this.assertExists(fileName); const modulePath = this.fs.resolve(this.outDir, fileName); return this.fs.readFile(modulePath); } enableMultipleCompilations(): void { this.changedResources = new Set(); this.multiCompileHostExt = new MultiCompileHostExt(this.fs); setWrapHostForTest(makeWrapHost(this.multiCompileHostExt)); } /** * Installs a compiler host that allows for asynchronous reading of resources by implementing the * `CompilerHost.readResource` method. Note that only asynchronous compilations are affected, as * synchronous compilations do not use the asynchronous resource loader. */ enablePreloading(): void { setWrapHostForTest(makeWrapHost(new ResourceLoadingCompileHost(this.fs))); } addCommandLineArgs(...args: string[]): void { this.commandLineArgs.push(...args); } flushWrittenFileTracking(): void { if (this.multiCompileHostExt === null) { throw new Error(`Not tracking written files - call enableMultipleCompilations()`); } this.changedResources!.clear(); this.multiCompileHostExt.flushWrittenFileTracking(); } getTsProgram(): ts.Program { if (this.oldProgram === null) { throw new Error('No ts.Program has been created yet.'); } return this.oldProgram.getTsProgram(); } getReuseTsProgram(): ts.Program { if (this.oldProgram === null) { throw new Error('No ts.Program has been created yet.'); } return (this.oldProgram as NgtscProgram).getReuseTsProgram(); } /** * Older versions of the CLI do not provide the `CompilerHost.getModifiedResourceFiles()` method. * This results in the `changedResources` set being `null`. */ simulateLegacyCLICompilerHost() { this.changedResources = null; } getFilesWrittenSinceLastFlush(): Set { if (this.multiCompileHostExt === null) { throw new Error(`Not tracking written files - call enableMultipleCompilations()`); } const writtenFiles = new Set(); this.multiCompileHostExt.getFilesWrittenSinceLastFlush().forEach((rawFile) => { if (rawFile.startsWith(this.outDir)) { writtenFiles.add(rawFile.slice(this.outDir.length)); } }); return writtenFiles; } write(fileName: string, content: string) { const absFilePath = this.fs.resolve(this.basePath, fileName); if (this.multiCompileHostExt !== null) { this.multiCompileHostExt.invalidate(absFilePath); if (!fileName.endsWith('.ts')) { this.changedResources!.add(absFilePath); } } this.fs.ensureDir(this.fs.dirname(absFilePath)); this.fs.writeFile(absFilePath, content); } invalidateCachedFile(fileName: string): void { const absFilePath = this.fs.resolve(this.basePath, fileName); if (this.multiCompileHostExt === null) { throw new Error(`Not caching files - call enableMultipleCompilations()`); } this.multiCompileHostExt.invalidate(absFilePath); if (!fileName.endsWith('.ts')) { this.changedResources!.add(absFilePath); } } tsconfig( extraOpts: TsConfigOptions = {}, compilerOptions?: TsCompilerOptions, files?: string[], ): void { let tsconfig: {[key: string]: any} = { extends: './tsconfig-base.json', angularCompilerOptions: extraOpts, }; if (files !== undefined) { tsconfig['files'] = files; } if (compilerOptions !== undefined) { tsconfig['compilerOptions'] = compilerOptions; } this.write('tsconfig.json', JSON.stringify(tsconfig, null, 2)); if ( extraOpts['_useHostForImportGeneration'] || extraOpts['_useHostForImportAndAliasGeneration'] ) { setWrapHostForTest(makeWrapHost(new FileNameToModuleNameHost(this.fs))); } } /** * Run the compiler to completion, and assert that no errors occurred. */ driveMain(customTransformers?: CustomTransformers): void { const errorSpy = jasmine.createSpy('consoleError').and.callFake(console.error); let reuseProgram: {program: Program | undefined} | undefined = undefined; if (this.multiCompileHostExt !== null) { reuseProgram = { program: this.oldProgram || undefined, }; } const exitCode = main( this.commandLineArgs, errorSpy, undefined, customTransformers, reuseProgram, this.changedResources, ); expect(errorSpy).not.toHaveBeenCalled(); expect(exitCode).toBe(0); if (this.multiCompileHostExt !== null) { this.oldProgram = reuseProgram!.program!; } } /** * Run the compiler to completion, and return any `ts.Diagnostic` errors that may have occurred. */ driveDiagnostics(expectedExitCode?: number): ReadonlyArray { // ngtsc only produces ts.Diagnostic messages. let reuseProgram: {program: Program | undefined} | undefined = undefined; if (this.multiCompileHostExt !== null) { reuseProgram = { program: this.oldProgram || undefined, }; } const {exitCode, diagnostics} = mainDiagnosticsForTest( this.commandLineArgs, undefined, reuseProgram, this.changedResources, ); if (expectedExitCode !== undefined) { expect(exitCode) .withContext( `Expected program to exit with code ${expectedExitCode}, but it actually exited with code ${exitCode}.`, ) .toBe(expectedExitCode); } if (this.multiCompileHostExt !== null) { this.oldProgram = reuseProgram!.program!; } // In ngtsc, only `ts.Diagnostic`s are produced. return diagnostics as ReadonlyArray; } async driveDiagnosticsAsync(): Promise> { const {rootNames, options} = readNgcCommandLineAndConfiguration(this.commandLineArgs); const host = createCompilerHost({options}); const program = createProgram({rootNames, host, options}); await program.loadNgStructureAsync(); // ngtsc only produces ts.Diagnostic messages. return defaultGatherDiagnostics(program as api.Program) as ts.Diagnostic[]; } driveTemplateTypeChecker(): {program: ts.Program; checker: TemplateTypeChecker} { const {rootNames, options} = readNgcCommandLineAndConfiguration(this.commandLineArgs); const host = createCompilerHost({options}); const program = createProgram({rootNames, host, options}); const checker = (program as NgtscProgram).compiler.getTemplateTypeChecker(); return { program: program.getTsProgram(), checker, }; } driveIndexer(): Map { const {rootNames, options} = readNgcCommandLineAndConfiguration(this.commandLineArgs); const host = createCompilerHost({options}); const program = createProgram({rootNames, host, options}); return (program as NgtscProgram).getIndexedComponents(); } driveDocsExtraction(entryPoint: string): DocEntry[] { const {rootNames, options} = readNgcCommandLineAndConfiguration(this.commandLineArgs); const host = createCompilerHost({options}); const program = createProgram({rootNames, host, options}); return (program as NgtscProgram).getApiDocumentation(entryPoint, new Set()).entries; } driveDocsExtractionForSymbols(entryPoint: string): Map { const {rootNames, options} = readNgcCommandLineAndConfiguration(this.commandLineArgs); const host = createCompilerHost({options}); const program = createProgram({rootNames, host, options}); return (program as NgtscProgram).getApiDocumentation(entryPoint, new Set()).symbols; } driveXi18n(format: string, outputFileName: string, locale: string | null = null): void { const errorSpy = jasmine.createSpy('consoleError').and.callFake(console.error); const args = [...this.commandLineArgs, `--i18nFormat=${format}`, `--outFile=${outputFileName}`]; if (locale !== null) { args.push(`--locale=${locale}`); } const exitCode = mainXi18n(args, errorSpy); expect(errorSpy).not.toHaveBeenCalled(); expect(exitCode).toEqual(0); } driveHmr(fileName: string, className: string): string | null { const {rootNames, options} = readNgcCommandLineAndConfiguration(this.commandLineArgs); const host = createCompilerHost({options}); const program = createProgram({rootNames, host, options}); const sourceFile = program.getTsProgram().getSourceFile(fileName); if (sourceFile == null) { throw new Error(`Cannot find file at "${fileName}"`); } for (const node of sourceFile.statements) { if (ts.isClassDeclaration(node) && node.name != null && node.name.text === className) { return (program as NgtscProgram).compiler.emitHmrUpdateModule(node); } } throw new Error(`Cannot find class with name "${className}" in "${fileName}"`); } } class AugmentedCompilerHost extends NgtscTestCompilerHost { delegate!: ts.CompilerHost; } const ROOT_PREFIX = 'root/'; class FileNameToModuleNameHost extends AugmentedCompilerHost { fileNameToModuleName(importedFilePath: string): string { const relativeFilePath = relativeFrom( this.fs.relative(this.fs.pwd(), this.fs.resolve(importedFilePath)), ); const rootedPath = this.fs.join('root', relativeFilePath); return rootedPath.replace(/(\.d)?.ts$/, ''); } resolveModuleNames( moduleNames: string[], containingFile: string, reusedNames: string[] | undefined, redirectedReference: ts.ResolvedProjectReference | undefined, options: ts.CompilerOptions, ): (ts.ResolvedModule | undefined)[] { return moduleNames.map((moduleName) => { if (moduleName.startsWith(ROOT_PREFIX)) { // Strip the artificially added root prefix. moduleName = '/' + moduleName.slice(ROOT_PREFIX.length); } return ts.resolveModuleName( moduleName, containingFile, options, this, /* cache */ undefined, redirectedReference, ).resolvedModule; }); } } class MultiCompileHostExt extends AugmentedCompilerHost implements Partial { private cache = new Map(); private writtenFiles = new Set(); override getSourceFile( fileName: string, languageVersion: ts.ScriptTarget, onError?: (message: string) => void, shouldCreateNewSourceFile?: boolean, ): ts.SourceFile | undefined { if (this.cache.has(fileName)) { return this.cache.get(fileName)!; } const sf = super.getSourceFile(fileName, languageVersion); if (sf !== undefined) { this.cache.set(sf.fileName, sf); } return sf; } flushWrittenFileTracking(): void { this.writtenFiles.clear(); } override writeFile( fileName: string, data: string, writeByteOrderMark: boolean, onError: ((message: string) => void) | undefined, sourceFiles?: ReadonlyArray, ): void { super.writeFile(fileName, data, writeByteOrderMark, onError, sourceFiles); this.writtenFiles.add(fileName); } getFilesWrittenSinceLastFlush(): Set { return this.writtenFiles; } invalidate(fileName: string): void { this.cache.delete(fileName); } } class ResourceLoadingCompileHost extends AugmentedCompilerHost implements api.CompilerHost { readResource(fileName: string): Promise | string { const resource = this.readFile(fileName); if (resource === undefined) { throw new Error(`Resource ${fileName} not found`); } return resource; } } function makeWrapHost(wrapped: AugmentedCompilerHost): (host: ts.CompilerHost) => ts.CompilerHost { return (delegate) => { wrapped.delegate = delegate; return new Proxy(delegate, { get: (target: ts.CompilerHost, name: string): any => { if ((wrapped as any)[name] !== undefined) { return (wrapped as any)[name]!.bind(wrapped); } return (target as any)[name]; }, }); }; }