feat(ivy): introduce missing-injectable migration for google3 (#30956)
Introduces a new migration schematic for adding the "@Injectable()" decorator to provider classes which are currently not migrated. Previously in ViewEngine, classes which are declared as providers sometimes don't require the "@Injectable()" decorator (e.g. https://stackblitz.com/edit/angular-hpo7gw) With Ivy, provider classes need to be explicitly decorated with the "@Injectable()" decorator if they are declared as providers of a given module. This commit introduces a migration schematic which automatically adds the explicit decorator to places where the decorator is currently missing. The migration logic is designed in a CLI devkit and TSlint agnostic way so that we can also have this migration run as part of a public CLI migration w/ `ng update`. This will be handled as part of a follow-up to reiterate on console output etc. Resolves FW-1371 PR Close #30956
This commit is contained in:
		
							parent
							
								
									9eefe25e2f
								
							
						
					
					
						commit
						9f2ae5d6ff
					
				| @ -0,0 +1,21 @@ | ||||
| load("//tools:defaults.bzl", "ts_library") | ||||
| 
 | ||||
| ts_library( | ||||
|     name = "missing-injectable", | ||||
|     srcs = glob(["**/*.ts"]), | ||||
|     tsconfig = "//packages/core/schematics:tsconfig.json", | ||||
|     visibility = [ | ||||
|         "//packages/core/schematics:__pkg__", | ||||
|         "//packages/core/schematics/migrations/missing-injectable/google3:__pkg__", | ||||
|         "//packages/core/schematics/test:__pkg__", | ||||
|     ], | ||||
|     deps = [ | ||||
|         "//packages/compiler-cli/src/ngtsc/imports", | ||||
|         "//packages/compiler-cli/src/ngtsc/partial_evaluator", | ||||
|         "//packages/compiler-cli/src/ngtsc/reflection", | ||||
|         "//packages/core/schematics/utils", | ||||
|         "@npm//@angular-devkit/schematics", | ||||
|         "@npm//@types/node", | ||||
|         "@npm//typescript", | ||||
|     ], | ||||
| ) | ||||
| @ -0,0 +1,13 @@ | ||||
| load("//tools:defaults.bzl", "ts_library") | ||||
| 
 | ||||
| ts_library( | ||||
|     name = "google3", | ||||
|     srcs = glob(["**/*.ts"]), | ||||
|     tsconfig = "//packages/core/schematics:tsconfig.json", | ||||
|     visibility = ["//packages/core/schematics/test:__pkg__"], | ||||
|     deps = [ | ||||
|         "//packages/core/schematics/migrations/missing-injectable", | ||||
|         "@npm//tslint", | ||||
|         "@npm//typescript", | ||||
|     ], | ||||
| ) | ||||
| @ -0,0 +1,64 @@ | ||||
| /** | ||||
|  * @license | ||||
|  * Copyright Google Inc. 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.io/license
 | ||||
|  */ | ||||
| 
 | ||||
| import {RuleFailure, Rules} from 'tslint'; | ||||
| import * as ts from 'typescript'; | ||||
| import {NgModuleCollector} from '../module_collector'; | ||||
| import {MissingInjectableTransform} from '../transform'; | ||||
| import {TslintUpdateRecorder} from './tslint_update_recorder'; | ||||
| 
 | ||||
| /** | ||||
|  * TSLint rule that flags classes which are declared as providers in NgModules but | ||||
|  * aren't decorated with any Angular decorator. | ||||
|  */ | ||||
| export class Rule extends Rules.TypedRule { | ||||
|   applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] { | ||||
|     const ruleName = this.ruleName; | ||||
|     const typeChecker = program.getTypeChecker(); | ||||
|     const sourceFiles = program.getSourceFiles().filter( | ||||
|         s => !s.isDeclarationFile && !program.isSourceFileFromExternalLibrary(s)); | ||||
|     const moduleCollector = new NgModuleCollector(typeChecker); | ||||
|     const failures: RuleFailure[] = []; | ||||
| 
 | ||||
|     // Analyze source files by detecting all NgModule definitions.
 | ||||
|     sourceFiles.forEach(sourceFile => moduleCollector.visitNode(sourceFile)); | ||||
| 
 | ||||
|     const {resolvedModules} = moduleCollector; | ||||
|     const transformer = new MissingInjectableTransform(typeChecker, getUpdateRecorder); | ||||
|     const updateRecorders = new Map<ts.SourceFile, TslintUpdateRecorder>(); | ||||
| 
 | ||||
|     resolvedModules.forEach(module => { | ||||
|       transformer.migrateModule(module).forEach(({message, node}) => { | ||||
|         // Only report failures for the current source file that is visited.
 | ||||
|         if (node.getSourceFile() === sourceFile) { | ||||
|           failures.push( | ||||
|               new RuleFailure(node.getSourceFile(), node.getStart(), 0, message, ruleName)); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     // Record the changes collected in the import manager and NgModule manager.
 | ||||
|     transformer.recordChanges(); | ||||
| 
 | ||||
|     if (updateRecorders.has(sourceFile)) { | ||||
|       failures.push(...updateRecorders.get(sourceFile) !.failures); | ||||
|     } | ||||
| 
 | ||||
|     return failures; | ||||
| 
 | ||||
|     /** Gets the update recorder for the specified source file. */ | ||||
|     function getUpdateRecorder(sourceFile: ts.SourceFile): TslintUpdateRecorder { | ||||
|       if (updateRecorders.has(sourceFile)) { | ||||
|         return updateRecorders.get(sourceFile) !; | ||||
|       } | ||||
|       const recorder = new TslintUpdateRecorder(ruleName, sourceFile); | ||||
|       updateRecorders.set(sourceFile, recorder); | ||||
|       return recorder; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,58 @@ | ||||
| /** | ||||
|  * @license | ||||
|  * Copyright Google Inc. 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.io/license
 | ||||
|  */ | ||||
| 
 | ||||
| import {Replacement, RuleFailure} from 'tslint'; | ||||
| import * as ts from 'typescript'; | ||||
| 
 | ||||
| import {UpdateRecorder} from '../update_recorder'; | ||||
| 
 | ||||
| export class TslintUpdateRecorder implements UpdateRecorder { | ||||
|   failures: RuleFailure[] = []; | ||||
| 
 | ||||
|   constructor(private ruleName: string, private sourceFile: ts.SourceFile) {} | ||||
| 
 | ||||
|   addClassDecorator(node: ts.ClassDeclaration, decoratorText: string, moduleName: string) { | ||||
|     // Adding a decorator should be the last replacement. Replacements/rule failures
 | ||||
|     // are handled in reverse and in case a decorator and import are inserted at
 | ||||
|     // the start of the file, the class decorator should come after the import.
 | ||||
|     this.failures.unshift(new RuleFailure( | ||||
|         this.sourceFile, node.getStart(), 0, `Class needs to be decorated with ` + | ||||
|             `"${decoratorText}" because it has been provided by "${moduleName}".`, | ||||
|         this.ruleName, Replacement.appendText(node.getStart(), `${decoratorText}\n`))); | ||||
|   } | ||||
| 
 | ||||
|   addNewImport(start: number, importText: string) { | ||||
|     this.failures.push(new RuleFailure( | ||||
|         this.sourceFile, start, 0, `Source file needs to have import: "${importText}"`, | ||||
|         this.ruleName, Replacement.appendText(start, importText))); | ||||
|   } | ||||
| 
 | ||||
|   updateExistingImport(namedBindings: ts.NamedImports, newNamedBindings: string): void { | ||||
|     const fix = [ | ||||
|       Replacement.deleteText(namedBindings.getStart(), namedBindings.getWidth()), | ||||
|       Replacement.appendText(namedBindings.getStart(), newNamedBindings), | ||||
|     ]; | ||||
|     this.failures.push(new RuleFailure( | ||||
|         this.sourceFile, namedBindings.getStart(), namedBindings.getEnd(), | ||||
|         `Import needs to be updated to import symbols: "${newNamedBindings}"`, this.ruleName, fix)); | ||||
|   } | ||||
| 
 | ||||
|   replaceDecorator(decorator: ts.Node, newText: string, moduleName: string): void { | ||||
|     const fix = [ | ||||
|       Replacement.deleteText(decorator.getStart(), decorator.getWidth()), | ||||
|       Replacement.appendText(decorator.getStart(), newText), | ||||
|     ]; | ||||
|     this.failures.push(new RuleFailure( | ||||
|         this.sourceFile, decorator.getStart(), decorator.getEnd(), | ||||
|         `Decorator needs to be replaced with "${newText}" because it has been provided ` + | ||||
|             `by "${moduleName}"`, | ||||
|         this.ruleName, fix)); | ||||
|   } | ||||
| 
 | ||||
|   commitUpdate() {} | ||||
| } | ||||
| @ -0,0 +1,254 @@ | ||||
| /** | ||||
|  * @license | ||||
|  * Copyright Google Inc. 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.io/license
 | ||||
|  */ | ||||
| 
 | ||||
| import {dirname, resolve} from 'path'; | ||||
| import * as ts from 'typescript'; | ||||
| import {UpdateRecorder} from './update_recorder'; | ||||
| 
 | ||||
| /** | ||||
|  * Import manager that can be used to add TypeScript imports to given source | ||||
|  * files. The manager ensures that multiple transformations are applied properly | ||||
|  * without shifted offsets and that similar existing import declarations are re-used. | ||||
|  */ | ||||
| export class ImportManager { | ||||
|   /** Map of import declarations that need to be updated to include the given symbols. */ | ||||
|   private updatedImports = | ||||
|       new Map<ts.ImportDeclaration, {propertyName?: ts.Identifier, importName: ts.Identifier}[]>(); | ||||
|   /** Map of source-files and their previously used identifier names. */ | ||||
|   private usedIdentifierNames = new Map<ts.SourceFile, string[]>(); | ||||
|   /** | ||||
|    * Array of previously resolved symbol imports. Cache can be re-used to return | ||||
|    * the same identifier without checking the source-file again. | ||||
|    */ | ||||
|   private importCache: { | ||||
|     sourceFile: ts.SourceFile, | ||||
|     symbolName: string|null, | ||||
|     moduleName: string, | ||||
|     identifier: ts.Identifier | ||||
|   }[] = []; | ||||
| 
 | ||||
|   constructor( | ||||
|       private getUpdateRecorder: (sf: ts.SourceFile) => UpdateRecorder, | ||||
|       private printer: ts.Printer) {} | ||||
| 
 | ||||
|   /** | ||||
|    * Adds an import to the given source-file and returns the TypeScript | ||||
|    * identifier that can be used to access the newly imported symbol. | ||||
|    */ | ||||
|   addImportToSourceFile( | ||||
|       sourceFile: ts.SourceFile, symbolName: string|null, moduleName: string, | ||||
|       typeImport = false): ts.Expression { | ||||
|     const sourceDir = dirname(sourceFile.fileName); | ||||
|     let importStartIndex = 0; | ||||
|     let existingImport: ts.ImportDeclaration|null = null; | ||||
| 
 | ||||
|     // In case the given import has been already generated previously, we just return
 | ||||
|     // the previous generated identifier in order to avoid duplicate generated imports.
 | ||||
|     const cachedImport = this.importCache.find( | ||||
|         c => c.sourceFile === sourceFile && c.symbolName === symbolName && | ||||
|             c.moduleName === moduleName); | ||||
|     if (cachedImport) { | ||||
|       return cachedImport.identifier; | ||||
|     } | ||||
| 
 | ||||
|     // Walk through all source-file top-level statements and search for import declarations
 | ||||
|     // that already match the specified "moduleName" and can be updated to import the
 | ||||
|     // given symbol. If no matching import can be found, the last import in the source-file
 | ||||
|     // will be used as starting point for a new import that will be generated.
 | ||||
|     for (let i = sourceFile.statements.length - 1; i >= 0; i--) { | ||||
|       const statement = sourceFile.statements[i]; | ||||
| 
 | ||||
|       if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || | ||||
|           !statement.importClause) { | ||||
|         continue; | ||||
|       } | ||||
| 
 | ||||
|       if (importStartIndex === 0) { | ||||
|         importStartIndex = this._getEndPositionOfNode(statement); | ||||
|       } | ||||
| 
 | ||||
|       const moduleSpecifier = statement.moduleSpecifier.text; | ||||
| 
 | ||||
|       if (moduleSpecifier.startsWith('.') && | ||||
|               resolve(sourceDir, moduleSpecifier) !== resolve(sourceDir, moduleName) || | ||||
|           moduleSpecifier !== moduleName) { | ||||
|         continue; | ||||
|       } | ||||
| 
 | ||||
|       if (statement.importClause.namedBindings) { | ||||
|         const namedBindings = statement.importClause.namedBindings; | ||||
| 
 | ||||
|         // In case a "Type" symbol is imported, we can't use namespace imports
 | ||||
|         // because these only export symbols available at runtime (no types)
 | ||||
|         if (ts.isNamespaceImport(namedBindings) && !typeImport) { | ||||
|           return ts.createPropertyAccess( | ||||
|               ts.createIdentifier(namedBindings.name.text), | ||||
|               ts.createIdentifier(symbolName || 'default')); | ||||
|         } else if (ts.isNamedImports(namedBindings) && symbolName) { | ||||
|           const existingElement = namedBindings.elements.find( | ||||
|               e => | ||||
|                   e.propertyName ? e.propertyName.text === symbolName : e.name.text === symbolName); | ||||
| 
 | ||||
|           if (existingElement) { | ||||
|             return ts.createIdentifier(existingElement.name.text); | ||||
|           } | ||||
| 
 | ||||
|           // In case the symbol could not be found in an existing import, we
 | ||||
|           // keep track of the import declaration as it can be updated to include
 | ||||
|           // the specified symbol name without having to create a new import.
 | ||||
|           existingImport = statement; | ||||
|         } | ||||
|       } else if (statement.importClause.name && !symbolName) { | ||||
|         return ts.createIdentifier(statement.importClause.name.text); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     if (existingImport) { | ||||
|       const propertyIdentifier = ts.createIdentifier(symbolName !); | ||||
|       const generatedUniqueIdentifier = this._getUniqueIdentifier(sourceFile, symbolName !); | ||||
|       const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName; | ||||
|       const importName = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier; | ||||
| 
 | ||||
|       // Since it can happen that multiple classes need to be imported within the
 | ||||
|       // specified source file and we want to add the identifiers to the existing
 | ||||
|       // import declaration, we need to keep track of the updated import declarations.
 | ||||
|       // We can't directly update the import declaration for each identifier as this
 | ||||
|       // would throw off the recorder offsets. We need to keep track of the new identifiers
 | ||||
|       // for the import and perform the import transformation as batches per source-file.
 | ||||
|       this.updatedImports.set( | ||||
|           existingImport, (this.updatedImports.get(existingImport) || []).concat({ | ||||
|             propertyName: needsGeneratedUniqueName ? propertyIdentifier : undefined, | ||||
|             importName: importName, | ||||
|           })); | ||||
| 
 | ||||
|       // Keep track of all updated imports so that we don't generate duplicate
 | ||||
|       // similar imports as these can't be statically analyzed in the source-file yet.
 | ||||
|       this.importCache.push({sourceFile, moduleName, symbolName, identifier: importName}); | ||||
| 
 | ||||
|       return importName; | ||||
|     } | ||||
| 
 | ||||
|     let identifier: ts.Identifier|null = null; | ||||
|     let newImport: ts.ImportDeclaration|null = null; | ||||
| 
 | ||||
|     if (symbolName) { | ||||
|       const propertyIdentifier = ts.createIdentifier(symbolName); | ||||
|       const generatedUniqueIdentifier = this._getUniqueIdentifier(sourceFile, symbolName); | ||||
|       const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName; | ||||
|       identifier = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier; | ||||
| 
 | ||||
|       newImport = ts.createImportDeclaration( | ||||
|           undefined, undefined, | ||||
|           ts.createImportClause( | ||||
|               undefined, | ||||
|               ts.createNamedImports([ts.createImportSpecifier( | ||||
|                   needsGeneratedUniqueName ? propertyIdentifier : undefined, identifier)])), | ||||
|           ts.createStringLiteral(moduleName)); | ||||
|     } else { | ||||
|       identifier = this._getUniqueIdentifier(sourceFile, 'defaultExport'); | ||||
|       newImport = ts.createImportDeclaration( | ||||
|           undefined, undefined, ts.createImportClause(identifier, undefined), | ||||
|           ts.createStringLiteral(moduleName)); | ||||
|     } | ||||
| 
 | ||||
|     const newImportText = this.printer.printNode(ts.EmitHint.Unspecified, newImport, sourceFile); | ||||
|     // If the import is generated at the start of the source file, we want to add
 | ||||
|     // a new-line after the import. Otherwise if the import is generated after an
 | ||||
|     // existing import, we need to prepend a new-line so that the import is not on
 | ||||
|     // the same line as the existing import anchor.
 | ||||
|     this.getUpdateRecorder(sourceFile) | ||||
|         .addNewImport( | ||||
|             importStartIndex, importStartIndex === 0 ? `${newImportText}\n` : `\n${newImportText}`); | ||||
| 
 | ||||
|     // Keep track of all generated imports so that we don't generate duplicate
 | ||||
|     // similar imports as these can't be statically analyzed in the source-file yet.
 | ||||
|     this.importCache.push({sourceFile, symbolName, moduleName, identifier}); | ||||
| 
 | ||||
|     return identifier; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Stores the collected import changes within the appropriate update recorders. The | ||||
|    * updated imports can only be updated *once* per source-file because previous updates | ||||
|    * could otherwise shift the source-file offsets. | ||||
|    */ | ||||
|   recordChanges() { | ||||
|     this.updatedImports.forEach((expressions, importDecl) => { | ||||
|       const sourceFile = importDecl.getSourceFile(); | ||||
|       const recorder = this.getUpdateRecorder(sourceFile); | ||||
|       const namedBindings = importDecl.importClause !.namedBindings as ts.NamedImports; | ||||
|       const newNamedBindings = ts.updateNamedImports( | ||||
|           namedBindings, | ||||
|           namedBindings.elements.concat(expressions.map( | ||||
|               ({propertyName, importName}) => ts.createImportSpecifier(propertyName, importName)))); | ||||
| 
 | ||||
|       const newNamedBindingsText = | ||||
|           this.printer.printNode(ts.EmitHint.Unspecified, newNamedBindings, sourceFile); | ||||
|       recorder.updateExistingImport(namedBindings, newNamedBindingsText); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   /** Gets an unique identifier with a base name for the given source file. */ | ||||
|   private _getUniqueIdentifier(sourceFile: ts.SourceFile, baseName: string): ts.Identifier { | ||||
|     if (this.isUniqueIdentifierName(sourceFile, baseName)) { | ||||
|       this._recordUsedIdentifier(sourceFile, baseName); | ||||
|       return ts.createIdentifier(baseName); | ||||
|     } | ||||
| 
 | ||||
|     let name = null; | ||||
|     let counter = 1; | ||||
|     do { | ||||
|       name = `${baseName}_${counter++}`; | ||||
|     } while (!this.isUniqueIdentifierName(sourceFile, name)); | ||||
| 
 | ||||
|     this._recordUsedIdentifier(sourceFile, name !); | ||||
|     return ts.createIdentifier(name !); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Checks whether the specified identifier name is used within the given | ||||
|    * source file. | ||||
|    */ | ||||
|   private isUniqueIdentifierName(sourceFile: ts.SourceFile, name: string) { | ||||
|     if (this.usedIdentifierNames.has(sourceFile) && | ||||
|         this.usedIdentifierNames.get(sourceFile) !.indexOf(name) !== -1) { | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
|     // Walk through the source file and search for an identifier matching
 | ||||
|     // the given name. In that case, it's not guaranteed that this name
 | ||||
|     // is unique in the given declaration scope and we just return false.
 | ||||
|     const nodeQueue: ts.Node[] = [sourceFile]; | ||||
|     while (nodeQueue.length) { | ||||
|       const node = nodeQueue.shift() !; | ||||
|       if (ts.isIdentifier(node) && node.text === name) { | ||||
|         return false; | ||||
|       } | ||||
|       nodeQueue.push(...node.getChildren()); | ||||
|     } | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   private _recordUsedIdentifier(sourceFile: ts.SourceFile, identifierName: string) { | ||||
|     this.usedIdentifierNames.set( | ||||
|         sourceFile, (this.usedIdentifierNames.get(sourceFile) || []).concat(identifierName)); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Determines the full end of a given node. By default the end position of a node is | ||||
|    * before all trailing comments. This could mean that generated imports shift comments. | ||||
|    */ | ||||
|   private _getEndPositionOfNode(node: ts.Node) { | ||||
|     const nodeEndPos = node.getEnd(); | ||||
|     const commentRanges = ts.getTrailingCommentRanges(node.getSourceFile().text, nodeEndPos); | ||||
|     if (!commentRanges || !commentRanges.length) { | ||||
|       return nodeEndPos; | ||||
|     } | ||||
|     return commentRanges[commentRanges.length - 1] !.end; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										131
									
								
								packages/core/schematics/migrations/missing-injectable/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								packages/core/schematics/migrations/missing-injectable/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,131 @@ | ||||
| /** | ||||
|  * @license | ||||
|  * Copyright Google Inc. 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.io/license
 | ||||
|  */ | ||||
| 
 | ||||
| import {Rule, SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics'; | ||||
| import {dirname, relative} from 'path'; | ||||
| import * as ts from 'typescript'; | ||||
| 
 | ||||
| import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; | ||||
| import {parseTsconfigFile} from '../../utils/typescript/parse_tsconfig'; | ||||
| 
 | ||||
| import {NgModuleCollector} from './module_collector'; | ||||
| import {MissingInjectableTransform} from './transform'; | ||||
| import {UpdateRecorder} from './update_recorder'; | ||||
| 
 | ||||
| /** Entry point for the V9 "missing @Injectable" schematic. */ | ||||
| export default function(): Rule { | ||||
|   return (tree: Tree, ctx: SchematicContext) => { | ||||
|     const {buildPaths, testPaths} = getProjectTsConfigPaths(tree); | ||||
|     const basePath = process.cwd(); | ||||
|     const failures: string[] = []; | ||||
| 
 | ||||
|     ctx.logger.info('------ Missing @Injectable migration ------'); | ||||
|     if (!buildPaths.length && !testPaths.length) { | ||||
|       throw new SchematicsException( | ||||
|           'Could not find any tsconfig file. Cannot add the "@Injectable" decorator to providers ' + | ||||
|           'which don\'t have that decorator set.'); | ||||
|     } | ||||
| 
 | ||||
|     for (const tsconfigPath of [...buildPaths, ...testPaths]) { | ||||
|       failures.push(...runMissingInjectableMigration(tree, tsconfigPath, basePath)); | ||||
|     } | ||||
| 
 | ||||
|     if (failures.length) { | ||||
|       ctx.logger.info('Could not migrate all providers automatically. Please'); | ||||
|       ctx.logger.info('manually migrate the following instances:'); | ||||
|       failures.forEach(message => ctx.logger.warn(`⮑   ${message}`)); | ||||
|     } else { | ||||
|       ctx.logger.info('Successfully migrated all undecorated providers.'); | ||||
|     } | ||||
|     ctx.logger.info('-------------------------------------------'); | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| function runMissingInjectableMigration( | ||||
|     tree: Tree, tsconfigPath: string, basePath: string): string[] { | ||||
|   const parsed = parseTsconfigFile(tsconfigPath, dirname(tsconfigPath)); | ||||
|   const host = ts.createCompilerHost(parsed.options, true); | ||||
|   const failures: string[] = []; | ||||
| 
 | ||||
|   // We need to overwrite the host "readFile" method, as we want the TypeScript
 | ||||
|   // program to be based on the file contents in the virtual file tree.
 | ||||
|   host.readFile = fileName => { | ||||
|     const buffer = tree.read(relative(basePath, fileName)); | ||||
|     // Strip BOM because TypeScript respects this character and it ultimately
 | ||||
|     // results in shifted offsets since the CLI UpdateRecorder tries to
 | ||||
|     // automatically account for the BOM character.
 | ||||
|     // https://github.com/angular/angular-cli/issues/14558
 | ||||
|     return buffer ? buffer.toString().replace(/^\uFEFF/, '') : undefined; | ||||
|   }; | ||||
| 
 | ||||
|   const program = ts.createProgram(parsed.fileNames, parsed.options, host); | ||||
|   const typeChecker = program.getTypeChecker(); | ||||
|   const moduleCollector = new NgModuleCollector(typeChecker); | ||||
|   const sourceFiles = program.getSourceFiles().filter( | ||||
|       f => !f.isDeclarationFile && !program.isSourceFileFromExternalLibrary(f)); | ||||
| 
 | ||||
|   // Analyze source files by detecting all modules.
 | ||||
|   sourceFiles.forEach(sourceFile => moduleCollector.visitNode(sourceFile)); | ||||
| 
 | ||||
|   const {resolvedModules} = moduleCollector; | ||||
|   const transformer = new MissingInjectableTransform(typeChecker, getUpdateRecorder); | ||||
|   const updateRecorders = new Map<ts.SourceFile, UpdateRecorder>(); | ||||
| 
 | ||||
|   resolvedModules.forEach(module => { | ||||
|     transformer.migrateModule(module).forEach(({message, node}) => { | ||||
|       const nodeSourceFile = node.getSourceFile(); | ||||
|       const relativeFilePath = relative(basePath, nodeSourceFile.fileName); | ||||
|       const {line, character} = | ||||
|           ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()); | ||||
|       failures.push(`${relativeFilePath}@${line + 1}:${character + 1}: ${message}`); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   // Record the changes collected in the import manager and transformer.
 | ||||
|   transformer.recordChanges(); | ||||
| 
 | ||||
|   // Walk through each update recorder and commit the update. We need to commit the
 | ||||
|   // updates in batches per source file as there can be only one recorder per source
 | ||||
|   // file in order to avoid shift character offsets.
 | ||||
|   updateRecorders.forEach(recorder => recorder.commitUpdate()); | ||||
| 
 | ||||
|   return failures; | ||||
| 
 | ||||
|   /** Gets the update recorder for the specified source file. */ | ||||
|   function getUpdateRecorder(sourceFile: ts.SourceFile): UpdateRecorder { | ||||
|     if (updateRecorders.has(sourceFile)) { | ||||
|       return updateRecorders.get(sourceFile) !; | ||||
|     } | ||||
|     const treeRecorder = tree.beginUpdate(relative(basePath, sourceFile.fileName)); | ||||
|     const recorder: UpdateRecorder = { | ||||
|       addClassDecorator(node: ts.ClassDeclaration, text: string) { | ||||
|         // New imports should be inserted at the left while decorators should be inserted
 | ||||
|         // at the right in order to ensure that imports are inserted before the decorator
 | ||||
|         // if the start position of import and decorator is the source file start.
 | ||||
|         treeRecorder.insertRight(node.getStart(), `${text}\n`); | ||||
|       }, | ||||
|       replaceDecorator(decorator: ts.Decorator, newText: string) { | ||||
|         treeRecorder.remove(decorator.getStart(), decorator.getWidth()); | ||||
|         treeRecorder.insertRight(decorator.getStart(), newText); | ||||
|       }, | ||||
|       addNewImport(start: number, importText: string) { | ||||
|         // New imports should be inserted at the left while decorators should be inserted
 | ||||
|         // at the right in order to ensure that imports are inserted before the decorator
 | ||||
|         // if the start position of import and decorator is the source file start.
 | ||||
|         treeRecorder.insertLeft(start, importText); | ||||
|       }, | ||||
|       updateExistingImport(namedBindings: ts.NamedImports, newNamedBindings: string) { | ||||
|         treeRecorder.remove(namedBindings.getStart(), namedBindings.getWidth()); | ||||
|         treeRecorder.insertRight(namedBindings.getStart(), newNamedBindings); | ||||
|       }, | ||||
|       commitUpdate() { tree.commitUpdate(treeRecorder); } | ||||
|     }; | ||||
|     updateRecorders.set(sourceFile, recorder); | ||||
|     return recorder; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,68 @@ | ||||
| /** | ||||
|  * @license | ||||
|  * Copyright Google Inc. 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.io/license
 | ||||
|  */ | ||||
| 
 | ||||
| import * as ts from 'typescript'; | ||||
| 
 | ||||
| import {NgDecorator, getAngularDecorators} from '../../utils/ng_decorators'; | ||||
| import {getPropertyNameText} from '../../utils/typescript/property_name'; | ||||
| 
 | ||||
| export interface ResolvedNgModule { | ||||
|   name: string; | ||||
|   node: ts.ClassDeclaration; | ||||
|   decorator: NgDecorator; | ||||
|   providersExpr: ts.Expression|null; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Visitor that walks through specified TypeScript nodes and collects all | ||||
|  * found NgModule definitions. | ||||
|  */ | ||||
| export class NgModuleCollector { | ||||
|   resolvedModules: ResolvedNgModule[] = []; | ||||
| 
 | ||||
|   constructor(public typeChecker: ts.TypeChecker) {} | ||||
| 
 | ||||
|   visitNode(node: ts.Node) { | ||||
|     if (ts.isClassDeclaration(node)) { | ||||
|       this.visitClassDeclaration(node); | ||||
|     } | ||||
| 
 | ||||
|     ts.forEachChild(node, n => this.visitNode(n)); | ||||
|   } | ||||
| 
 | ||||
|   private visitClassDeclaration(node: ts.ClassDeclaration) { | ||||
|     if (!node.decorators || !node.decorators.length) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const ngDecorators = getAngularDecorators(this.typeChecker, node.decorators); | ||||
|     const ngModuleDecorator = ngDecorators.find(({name}) => name === 'NgModule'); | ||||
| 
 | ||||
|     if (ngModuleDecorator) { | ||||
|       this._visitNgModuleClass(node, ngModuleDecorator); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private _visitNgModuleClass(node: ts.ClassDeclaration, decorator: NgDecorator) { | ||||
|     const decoratorCall = decorator.node.expression; | ||||
|     const metadata = decoratorCall.arguments[0]; | ||||
| 
 | ||||
|     if (!metadata || !ts.isObjectLiteralExpression(metadata)) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const providersNode = metadata.properties.filter(ts.isPropertyAssignment) | ||||
|                               .find(p => getPropertyNameText(p.name) === 'providers'); | ||||
|     this.resolvedModules.push({ | ||||
|       name: node.name ? node.name.text : 'default', | ||||
|       node, | ||||
|       decorator, | ||||
|       providersExpr: providersNode !== undefined ? providersNode.initializer : null, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,130 @@ | ||||
| /** | ||||
|  * @license | ||||
|  * Copyright Google Inc. 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.io/license
 | ||||
|  */ | ||||
| 
 | ||||
| import {Reference} from '@angular/compiler-cli/src/ngtsc/imports'; | ||||
| import {DynamicValue, PartialEvaluator, ResolvedValue} from '@angular/compiler-cli/src/ngtsc/partial_evaluator'; | ||||
| import {TypeScriptReflectionHost} from '@angular/compiler-cli/src/ngtsc/reflection'; | ||||
| import * as ts from 'typescript'; | ||||
| 
 | ||||
| import {getAngularDecorators} from '../../utils/ng_decorators'; | ||||
| 
 | ||||
| import {ImportManager} from './import_manager'; | ||||
| import {ResolvedNgModule} from './module_collector'; | ||||
| import {UpdateRecorder} from './update_recorder'; | ||||
| 
 | ||||
| /** Name of decorators which imply that a given class does not need to be migrated. */ | ||||
| const NO_MIGRATE_DECORATORS = ['Injectable', 'Directive', 'Component', 'Pipe']; | ||||
| 
 | ||||
| export interface AnalysisFailure { | ||||
|   node: ts.Node; | ||||
|   message: string; | ||||
| } | ||||
| 
 | ||||
| export class MissingInjectableTransform { | ||||
|   private printer = ts.createPrinter(); | ||||
|   private importManager = new ImportManager(this.getUpdateRecorder, this.printer); | ||||
|   private partialEvaluator: PartialEvaluator; | ||||
| 
 | ||||
|   /** Set of provider class declarations which were already checked or migrated. */ | ||||
|   private visitedProviderClasses = new Set<ts.ClassDeclaration>(); | ||||
| 
 | ||||
|   constructor( | ||||
|       private typeChecker: ts.TypeChecker, | ||||
|       private getUpdateRecorder: (sf: ts.SourceFile) => UpdateRecorder) { | ||||
|     this.partialEvaluator = | ||||
|         new PartialEvaluator(new TypeScriptReflectionHost(typeChecker), typeChecker); | ||||
|   } | ||||
| 
 | ||||
|   recordChanges() { this.importManager.recordChanges(); } | ||||
| 
 | ||||
|   /** Migrates a given NgModule by walking through the referenced providers. */ | ||||
|   migrateModule(module: ResolvedNgModule): AnalysisFailure[] { | ||||
|     if (module.providersExpr === null) { | ||||
|       return []; | ||||
|     } | ||||
| 
 | ||||
|     const evaluatedExpr = this.partialEvaluator.evaluate(module.providersExpr); | ||||
| 
 | ||||
|     if (!Array.isArray(evaluatedExpr)) { | ||||
|       return [{ | ||||
|         node: module.providersExpr, | ||||
|         message: 'Providers of module are not statically analyzable.' | ||||
|       }]; | ||||
|     } | ||||
| 
 | ||||
|     return this._visitProviderResolvedValue(evaluatedExpr, module); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Migrates a given provider class if it is not decorated with | ||||
|    * any Angular decorator. | ||||
|    */ | ||||
|   migrateProviderClass(node: ts.ClassDeclaration, module: ResolvedNgModule) { | ||||
|     if (this.visitedProviderClasses.has(node)) { | ||||
|       return; | ||||
|     } | ||||
|     this.visitedProviderClasses.add(node); | ||||
| 
 | ||||
|     const sourceFile = node.getSourceFile(); | ||||
|     const ngDecorators = | ||||
|         node.decorators ? getAngularDecorators(this.typeChecker, node.decorators) : null; | ||||
| 
 | ||||
|     if (ngDecorators !== null && | ||||
|         ngDecorators.some(d => NO_MIGRATE_DECORATORS.indexOf(d.name) !== -1)) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const updateRecorder = this.getUpdateRecorder(sourceFile); | ||||
|     const importExpr = | ||||
|         this.importManager.addImportToSourceFile(sourceFile, 'Injectable', '@angular/core'); | ||||
|     const newDecoratorExpr = ts.createDecorator(ts.createCall(importExpr, undefined, undefined)); | ||||
|     const newDecoratorText = | ||||
|         this.printer.printNode(ts.EmitHint.Unspecified, newDecoratorExpr, sourceFile); | ||||
| 
 | ||||
| 
 | ||||
|     // In case the class is already decorated with "@Inject(..)", we replace the "@Inject"
 | ||||
|     // decorator with "@Injectable()" since using "@Inject(..)" on a class is a noop and
 | ||||
|     // most likely was meant to be "@Injectable()".
 | ||||
|     const existingInjectDecorator = | ||||
|         ngDecorators !== null ? ngDecorators.find(d => d.name === 'Inject') : null; | ||||
|     if (existingInjectDecorator) { | ||||
|       updateRecorder.replaceDecorator(existingInjectDecorator.node, newDecoratorText, module.name); | ||||
|     } else { | ||||
|       updateRecorder.addClassDecorator(node, newDecoratorText, module.name); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Visits the given resolved value of a provider. Providers can be nested in | ||||
|    * arrays and we need to recursively walk through the providers to be able to | ||||
|    * migrate all referenced provider classes. e.g. "providers: [[A, [B]]]". | ||||
|    */ | ||||
|   private _visitProviderResolvedValue(value: ResolvedValue, module: ResolvedNgModule): | ||||
|       AnalysisFailure[] { | ||||
|     if (value instanceof Reference && ts.isClassDeclaration(value.node)) { | ||||
|       this.migrateProviderClass(value.node, module); | ||||
|     } else if (value instanceof Map) { | ||||
|       if (!value.has('provide') || value.has('useValue') || value.has('useFactory')) { | ||||
|         return []; | ||||
|       } | ||||
|       if (value.has('useExisting')) { | ||||
|         return this._visitProviderResolvedValue(value.get('useExisting') !, module); | ||||
|       } else if (value.has('useClass')) { | ||||
|         return this._visitProviderResolvedValue(value.get('useClass') !, module); | ||||
|       } else { | ||||
|         return this._visitProviderResolvedValue(value.get('provide') !, module); | ||||
|       } | ||||
|     } else if (Array.isArray(value)) { | ||||
|       return value.reduce((res, v) => res.concat(this._visitProviderResolvedValue(v, module)), [ | ||||
|       ] as AnalysisFailure[]); | ||||
|     } else if (value instanceof DynamicValue) { | ||||
|       return [{node: value.node, message: `Provider is not statically analyzable.`}]; | ||||
|     } | ||||
|     return []; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,22 @@ | ||||
| /** | ||||
|  * @license | ||||
|  * Copyright Google Inc. 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.io/license
 | ||||
|  */ | ||||
| 
 | ||||
| import * as ts from 'typescript'; | ||||
| 
 | ||||
| /** | ||||
|  * Update recorder interface that is used to transform source files in a non-colliding | ||||
|  * way. Also this indirection makes it possible to re-use logic for both TSLint rules | ||||
|  * and CLI devkit schematic updates. | ||||
|  */ | ||||
| export interface UpdateRecorder { | ||||
|   addNewImport(start: number, importText: string): void; | ||||
|   updateExistingImport(namedBindings: ts.NamedImports, newNamedBindings: string): void; | ||||
|   addClassDecorator(node: ts.ClassDeclaration, text: string, moduleName: string): void; | ||||
|   replaceDecorator(node: ts.Decorator, newText: string, moduleName: string): void; | ||||
|   commitUpdate(): void; | ||||
| } | ||||
| @ -11,6 +11,8 @@ ts_library( | ||||
|     deps = [ | ||||
|         "//packages/core/schematics/migrations/injectable-pipe", | ||||
|         "//packages/core/schematics/migrations/injectable-pipe/google3", | ||||
|         "//packages/core/schematics/migrations/missing-injectable", | ||||
|         "//packages/core/schematics/migrations/missing-injectable/google3", | ||||
|         "//packages/core/schematics/migrations/move-document", | ||||
|         "//packages/core/schematics/migrations/renderer-to-renderer2", | ||||
|         "//packages/core/schematics/migrations/renderer-to-renderer2/google3", | ||||
|  | ||||
| @ -0,0 +1,227 @@ | ||||
| /** | ||||
|  * @license | ||||
|  * Copyright Google Inc. 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.io/license
 | ||||
|  */ | ||||
| 
 | ||||
| import {readFileSync, writeFileSync} from 'fs'; | ||||
| import {dirname, join} from 'path'; | ||||
| import * as shx from 'shelljs'; | ||||
| import {Configuration, Linter} from 'tslint'; | ||||
| 
 | ||||
| describe('Google3 missing injectable tslint rule', () => { | ||||
|   const rulesDirectory = dirname( | ||||
|       require.resolve('../../migrations/missing-injectable/google3/noMissingInjectableRule')); | ||||
| 
 | ||||
|   let tmpDir: string; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     tmpDir = join(process.env['TEST_TMPDIR'] !, 'google3-test'); | ||||
|     shx.mkdir('-p', tmpDir); | ||||
| 
 | ||||
|     writeFile('tsconfig.json', JSON.stringify({compilerOptions: {module: 'es2015'}})); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => shx.rm('-r', tmpDir)); | ||||
| 
 | ||||
|   function runTSLint(fix = true) { | ||||
|     const program = Linter.createProgram(join(tmpDir, 'tsconfig.json')); | ||||
|     const linter = new Linter({fix, rulesDirectory: [rulesDirectory]}, program); | ||||
|     const config = Configuration.parseConfigFile( | ||||
|         {rules: {'no-missing-injectable': true}, linterOptions: {typeCheck: true}}); | ||||
| 
 | ||||
|     program.getRootFileNames().forEach(fileName => { | ||||
|       linter.lint(fileName, program.getSourceFile(fileName) !.getFullText(), config); | ||||
|     }); | ||||
| 
 | ||||
|     return linter; | ||||
|   } | ||||
| 
 | ||||
|   function writeFile(fileName: string, content: string) { | ||||
|     writeFileSync(join(tmpDir, fileName), content); | ||||
|   } | ||||
| 
 | ||||
|   function getFile(fileName: string) { return readFileSync(join(tmpDir, fileName), 'utf8'); } | ||||
| 
 | ||||
|   it('should create proper failures for missing injectable providers', () => { | ||||
|     writeFile('index.ts', ` | ||||
|       import { NgModule } from '@angular/core'; | ||||
| 
 | ||||
|       export class A {} | ||||
| 
 | ||||
|       @NgModule({providers: [A]}) | ||||
|       export class AppModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     const linter = runTSLint(false); | ||||
|     const failures = linter.getResult().failures; | ||||
| 
 | ||||
|     expect(failures.length).toBe(2); | ||||
|     expect(failures[0].getFailure()) | ||||
|         .toMatch(/Class needs to be decorated with "@Injectable\(\)".*provided by "AppModule"/); | ||||
|     expect(failures[0].getStartPosition().getLineAndCharacter()).toEqual({line: 3, character: 6}); | ||||
|     expect(failures[1].getFailure()).toMatch(/Import needs to be updated to import.*Injectable/); | ||||
|     expect(failures[1].getStartPosition().getLineAndCharacter()).toEqual({line: 1, character: 13}); | ||||
|   }); | ||||
| 
 | ||||
|   it('should update provider classes which need to be migrated in Ivy', () => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {Pipe, Directive, Component, NgModule} from '@angular/core'; | ||||
|      | ||||
|       @Pipe() | ||||
|       export class WithPipe {} | ||||
|        | ||||
|       @Directive() | ||||
|       export class WithDirective {} | ||||
|        | ||||
|       @Component() | ||||
|       export class WithComponent {} | ||||
|        | ||||
|       export class MyServiceA {} | ||||
|       export class MyServiceB {} | ||||
|       export class MyServiceC {} | ||||
|       export class MyServiceD {} | ||||
|       export class MyServiceE {} | ||||
|       export class MyServiceF {} | ||||
|       export class MyServiceG {} | ||||
|            | ||||
|       @NgModule({providers: [ | ||||
|         WithPipe, | ||||
|         [ | ||||
|           WithDirective, | ||||
|           WithComponent, | ||||
|           MyServiceA, | ||||
|         ] | ||||
|         MyServiceB, | ||||
|         {provide: MyServiceC}, | ||||
|         {provide: null, useClass: MyServiceD}, | ||||
|         {provide: null, useExisting: MyServiceE}, | ||||
|         {provide: MyServiceF, useFactory: () => null}, | ||||
|         {provide: MyServiceG, useValue: null}, | ||||
|       ]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
| 
 | ||||
|     runTSLint(); | ||||
| 
 | ||||
|     expect(getFile('/index.ts')).toMatch(/'@angular\/core';\s+@Pipe\(\)\s+export class WithPipe/); | ||||
|     expect(getFile('/index.ts')) | ||||
|         .toMatch(/WithPipe {}\s+@Directive\(\)\s+export class WithDirective/); | ||||
|     expect(getFile('/index.ts')) | ||||
|         .toMatch(/WithDirective {}\s+@Component\(\)\s+export class WithComponent/); | ||||
|     expect(getFile('/index.ts')).toMatch(/@Injectable\(\)\s+export class MyServiceA/); | ||||
|     expect(getFile('/index.ts')).toMatch(/@Injectable\(\)\s+export class MyServiceB/); | ||||
|     expect(getFile('/index.ts')).toMatch(/@Injectable\(\)\s+export class MyServiceC/); | ||||
|     expect(getFile('/index.ts')).toMatch(/@Injectable\(\)\s+export class MyServiceD/); | ||||
|     expect(getFile('/index.ts')).toMatch(/@Injectable\(\)\s+export class MyServiceE/); | ||||
|     expect(getFile('/index.ts')).toMatch(/MyServiceE {}\s+export class MyServiceF/); | ||||
|     expect(getFile('/index.ts')).toMatch(/MyServiceF {}\s+export class MyServiceG/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate provider once if referenced in multiple NgModule definitions', () => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class ServiceA {} | ||||
|                  | ||||
|       @NgModule({providers: [ServiceA]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     writeFile('/second.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|       import {ServiceA} from './index'; | ||||
|        | ||||
|       export class ServiceB {} | ||||
|        | ||||
|       @NgModule({providers: [ServiceA, ServiceB]}) | ||||
|       export class SecondModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     runTSLint(); | ||||
| 
 | ||||
|     expect(getFile('/index.ts')) | ||||
|         .toMatch(/@angular\/core';\s+@Injectable\(\)\s+export class ServiceA/); | ||||
|     expect(getFile('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|     expect(getFile('/second.ts')).toMatch(/@Injectable\(\)\s+export class ServiceB/); | ||||
|     expect(getFile('/second.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should warn if a referenced provider could not be resolved', () => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|        | ||||
|       @NgModule({providers: [NotPresent]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     const linter = runTSLint(); | ||||
|     const failures = linter.getResult().failures; | ||||
| 
 | ||||
|     expect(failures.length).toBe(1); | ||||
|     expect(failures[0].getFailure()).toMatch(/Provider is not statically analyzable./); | ||||
|     expect(failures[0].getStartPosition().getLineAndCharacter()).toEqual({line: 3, character: 29}); | ||||
|   }); | ||||
| 
 | ||||
|   it('should warn if the module providers could not be resolved', () => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|        | ||||
|       @NgModule({providers: NOT_ANALYZABLE) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     const linter = runTSLint(); | ||||
|     const failures = linter.getResult().failures; | ||||
| 
 | ||||
|     expect(failures.length).toBe(1); | ||||
|     expect(failures[0].getFailure()).toMatch(/Providers of module.*not statically analyzable./); | ||||
|     expect(failures[0].getStartPosition().getLineAndCharacter()).toEqual({line: 3, character: 28}); | ||||
|   }); | ||||
| 
 | ||||
|   it('should create new import for @Injectable when migrating provider', () => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|       import {MyService, MySecondService} from './service'; | ||||
|                      | ||||
|       @NgModule({providers: [MyService, MySecondService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     writeFile('/service.ts', `export class MyService {}
 | ||||
|      | ||||
|       export class MySecondService {} | ||||
|     `);
 | ||||
| 
 | ||||
|     runTSLint(); | ||||
| 
 | ||||
|     expect(getFile('/service.ts')).toMatch(/@Injectable\(\)\s+export class MyService/); | ||||
|     expect(getFile('/service.ts')).toMatch(/@Injectable\(\)\s+export class MySecondService/); | ||||
|     expect(getFile('/service.ts')).toMatch(/import { Injectable } from "@angular\/core";/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should remove @Inject decorator for providers which are migrated', () => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|       import {MyService} from './service'; | ||||
|               | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     writeFile('/service.ts', ` | ||||
|       import {Inject} from '@angular/core'; | ||||
|      | ||||
|       @Inject() | ||||
|       export class MyService {} | ||||
|     `);
 | ||||
| 
 | ||||
|     runTSLint(); | ||||
| 
 | ||||
|     expect(getFile('/service.ts')).toMatch(/core';\s+@Injectable\(\)\s+export class MyService/); | ||||
|     expect(getFile('/service.ts')).toMatch(/import { Inject, Injectable } from '@angular\/core';/); | ||||
|   }); | ||||
| }); | ||||
| @ -0,0 +1,580 @@ | ||||
| /** | ||||
|  * @license | ||||
|  * Copyright Google Inc. 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.io/license
 | ||||
|  */ | ||||
| 
 | ||||
| import {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; | ||||
| import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; | ||||
| import {HostTree} from '@angular-devkit/schematics'; | ||||
| import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; | ||||
| import * as shx from 'shelljs'; | ||||
| 
 | ||||
| describe('Missing injectable migration', () => { | ||||
|   let runner: SchematicTestRunner; | ||||
|   let host: TempScopedNodeJsSyncHost; | ||||
|   let tree: UnitTestTree; | ||||
|   let tmpDirPath: string; | ||||
|   let previousWorkingDir: string; | ||||
|   let warnOutput: string[]; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     runner = new SchematicTestRunner('test', require.resolve('./test-migrations.json')); | ||||
|     host = new TempScopedNodeJsSyncHost(); | ||||
|     tree = new UnitTestTree(new HostTree(host)); | ||||
| 
 | ||||
|     writeFile('/tsconfig.json', JSON.stringify({ | ||||
|       compilerOptions: { | ||||
|         experimentalDecorators: true, | ||||
|         lib: ['es2015'], | ||||
|       } | ||||
|     })); | ||||
|     writeFile('/angular.json', JSON.stringify({ | ||||
|       projects: {t: {architect: {build: {options: {tsConfig: './tsconfig.json'}}}}} | ||||
|     })); | ||||
| 
 | ||||
|     warnOutput = []; | ||||
|     runner.logger.subscribe(logEntry => { | ||||
|       if (logEntry.level === 'warn') { | ||||
|         warnOutput.push(logEntry.message); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     previousWorkingDir = shx.pwd(); | ||||
|     tmpDirPath = getSystemPath(host.root); | ||||
| 
 | ||||
|     // Switch into the temporary directory path. This allows us to run
 | ||||
|     // the schematic against our custom unit test tree.
 | ||||
|     shx.cd(tmpDirPath); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     shx.cd(previousWorkingDir); | ||||
|     shx.rm('-r', tmpDirPath); | ||||
|   }); | ||||
| 
 | ||||
|   function writeFile(filePath: string, contents: string) { | ||||
|     host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); | ||||
|   } | ||||
| 
 | ||||
|   async function runMigration() { | ||||
|     await runner.runSchematicAsync('migration-missing-injectable', {}, tree).toPromise(); | ||||
|   } | ||||
| 
 | ||||
|   it('should migrate type provider in NgModule', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class MyService {} | ||||
|            | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class MyService/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate object literal provider in NgModule', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class MyService {} | ||||
|            | ||||
|       @NgModule({providers: [{provide: MyService}]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class MyService/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should not migrate object literal provider with "useValue" in NgModule', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class MyService {} | ||||
|            | ||||
|       @NgModule({providers: [{provide: MyService, useValue: null }]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).not.toContain('@Injectable'); | ||||
|   }); | ||||
| 
 | ||||
|   it('should not migrate object literal provider with "useFactory" in NgModule', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class MyService {} | ||||
|            | ||||
|       @NgModule({providers: [{provide: MyService, useFactory: () => null }]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).not.toContain('@Injectable'); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate object literal provider with "useExisting" in NgModule', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class MyService {} | ||||
|       export class MyToken {} | ||||
|            | ||||
|       @NgModule({providers: [{provide: MyToken, useExisting: MyService}]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class MyService/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/MyService {}\s+export class MyToken/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate object literal provider with "useClass" in NgModule', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class MyService {} | ||||
|       export class MyToken {} | ||||
|            | ||||
|       @NgModule({providers: [{provide: MyToken, useClass: MyService}]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class MyService/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/MyService {}\s+export class MyToken/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should not migrate provider which is already decorated with @Injectable', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {Injectable, NgModule} from '@angular/core'; | ||||
|      | ||||
|       @Injectable() | ||||
|       export class MyService {} | ||||
|            | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')) | ||||
|         .toMatch(/@angular\/core';\s+@Injectable\(\)\s+export class MyService/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should not migrate provider which is already decorated with @Directive', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {Directive, NgModule} from '@angular/core'; | ||||
|      | ||||
|       @Directive() | ||||
|       export class MyService {} | ||||
|            | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).not.toContain('@Injectable'); | ||||
|   }); | ||||
| 
 | ||||
|   it('should not migrate provider which is already decorated with @Component', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {Component, NgModule} from '@angular/core'; | ||||
|      | ||||
|       @Component() | ||||
|       export class MyService {} | ||||
|            | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).not.toContain('@Injectable'); | ||||
|   }); | ||||
| 
 | ||||
|   it('should not migrate provider which is already decorated with @Pipe', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {Pipe, NgModule} from '@angular/core'; | ||||
|      | ||||
|       @Pipe() | ||||
|       export class MyService {} | ||||
|            | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).not.toContain('@Injectable'); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate multiple providers in same NgModule', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class ServiceA {} | ||||
|       export class ServiceB {} | ||||
|            | ||||
|       @NgModule({providers: [ServiceA, ServiceB]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceA/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceB/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate multiple mixed providers in same NgModule', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class ServiceA {} | ||||
|       export class ServiceB {} | ||||
|       export class ServiceC {} | ||||
|            | ||||
|       @NgModule({ | ||||
|         providers: [ | ||||
|           ServiceA, | ||||
|           {provide: ServiceB}, | ||||
|           {provide: SomeToken, useClass: ServiceC}, | ||||
|         ]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceA/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceB/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceC/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
| 
 | ||||
|   it('should migrate multiple nested providers in same NgModule', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class ServiceA {} | ||||
|       export class ServiceB {} | ||||
|       export class ServiceC {} | ||||
|            | ||||
|       @NgModule({ | ||||
|         providers: [ | ||||
|           ServiceA, | ||||
|           [ | ||||
|             {provide: ServiceB}, | ||||
|             ServiceC, | ||||
|           ], | ||||
|         ]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceA/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceB/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceC/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate providers referenced through identifier', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class ServiceA {} | ||||
|       export class ServiceB {} | ||||
|        | ||||
|       const PROVIDERS = [ServiceA, ServiceB]; | ||||
|            | ||||
|       @NgModule({ | ||||
|         providers: PROVIDERS, | ||||
|       }) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceA/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceB/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate providers created through static analyzable function call', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class ServiceA {} | ||||
|       export class ServiceB {} | ||||
|        | ||||
|       export function createProviders(x: any) { | ||||
|         return [ServiceA, x] | ||||
|       } | ||||
|            | ||||
|       @NgModule({ | ||||
|         providers: createProviders(ServiceB), | ||||
|       }) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceA/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceB/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate providers which are computed through spread operator', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class ServiceA {} | ||||
|       export class ServiceB {} | ||||
|        | ||||
|       const otherServices = [ServiceB]; | ||||
|            | ||||
|       @NgModule({ | ||||
|         providers: [ServiceA, ...otherServices], | ||||
|       }) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceA/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/@Injectable\(\)\s+export class ServiceB/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should migrate provider once if referenced in multiple NgModule definitions', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|      | ||||
|       export class ServiceA {} | ||||
|                  | ||||
|       @NgModule({providers: [ServiceA]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     writeFile('/second.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|       import {ServiceA} from './index'; | ||||
|        | ||||
|       export class ServiceB {} | ||||
|        | ||||
|       @NgModule({providers: [ServiceA, ServiceB]}) | ||||
|       export class SecondModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/index.ts')) | ||||
|         .toMatch(/@angular\/core';\s+@Injectable\(\)\s+export class ServiceA/); | ||||
|     expect(tree.readContent('/index.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|     expect(tree.readContent('/second.ts')).toMatch(/@Injectable\(\)\s+export class ServiceB/); | ||||
|     expect(tree.readContent('/second.ts')).toMatch(/{ NgModule, Injectable } from '@angular\/core/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should create new import for @Injectable when migrating provider', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|       import {MyService, MySecondService} from './service'; | ||||
|                      | ||||
|       @NgModule({providers: [MyService, MySecondService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     writeFile('/service.ts', `export class MyService {}
 | ||||
|      | ||||
|       export class MySecondService {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/service.ts')).toMatch(/@Injectable\(\)\s+export class MyService/); | ||||
|     expect(tree.readContent('/service.ts')) | ||||
|         .toMatch(/@Injectable\(\)\s+export class MySecondService/); | ||||
|     expect(tree.readContent('/service.ts')).toMatch(/import { Injectable } from "@angular\/core";/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should re-use existing namespace import for importing @Injectable when migrating provider', | ||||
|      async() => { | ||||
|        writeFile('/index.ts', ` | ||||
|       import * as core from '@angular/core'; | ||||
| 
 | ||||
|       export class MyService { | ||||
|         constructor() { | ||||
|           console.log(core.isDevMode()); | ||||
|         } | ||||
|       } | ||||
|     `);
 | ||||
| 
 | ||||
|        writeFile('/app.module.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|       import {MyService} from './index'; | ||||
|      | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|        await runMigration(); | ||||
| 
 | ||||
|        expect(warnOutput.length).toBe(0); | ||||
|        expect(tree.readContent('/index.ts')) | ||||
|            .toMatch(/@core.Injectable\(\)\s+export class MyService/); | ||||
|      }); | ||||
| 
 | ||||
|   it('should warn if a referenced provider could not be resolved', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|        | ||||
|       @NgModule({providers: [NotPresent]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(1); | ||||
|     expect(warnOutput[0]).toMatch(/\s+index\.ts@4:30: Provider is not statically analyzable./); | ||||
|   }); | ||||
| 
 | ||||
|   it('should warn if the module providers could not be resolved', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|        | ||||
|       @NgModule({providers: NOT_ANALYZABLE) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(1); | ||||
|     expect(warnOutput[0]) | ||||
|         .toMatch(/\s+index\.ts@4:29: Providers of module.*not statically analyzable./); | ||||
|   }); | ||||
| 
 | ||||
|   it('should not throw if an empty @NgModule is analyzed', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|                | ||||
|       @NgModule() | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     try { | ||||
|       await runMigration(); | ||||
|     } catch (e) { | ||||
|       fail(e); | ||||
|     } | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|   }); | ||||
| 
 | ||||
|   it('should create new import for injectable after full end of last import statement', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|       import {MyService} from './service'; | ||||
|               | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     writeFile('/service.ts', ` | ||||
|       import * as a from 'a'; | ||||
|       import * as a from 'b'; // some comment
 | ||||
|      | ||||
|       export class MyService {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/service.ts')).toMatch(/@Injectable\(\)\s+export class MyService/); | ||||
|     expect(tree.readContent('/service.ts')) | ||||
|         .toMatch(/'b'; \/\/ some comment\s+import { Injectable } from "@angular\/core";/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should create new import at source file start with trailing new-line', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|       import {MyService} from './service'; | ||||
|               | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     writeFile('/service.ts', `/* @license */
 | ||||
|       export class MyService {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/service.ts')) | ||||
|         .toMatch( | ||||
|             /^import { Injectable } from "@angular\/core";\s+\/\* @license \*\/\s+@Injectable\(\)\s+export class MyService/); | ||||
|   }); | ||||
| 
 | ||||
|   it('should remove @Inject decorator for providers which are migrated', async() => { | ||||
|     writeFile('/index.ts', ` | ||||
|       import {NgModule} from '@angular/core'; | ||||
|       import {MyService} from './service'; | ||||
|               | ||||
|       @NgModule({providers: [MyService]}) | ||||
|       export class MyModule {} | ||||
|     `);
 | ||||
| 
 | ||||
|     writeFile('/service.ts', ` | ||||
|       import {Inject} from '@angular/core'; | ||||
|      | ||||
|       @Inject() | ||||
|       export class MyService {} | ||||
|     `);
 | ||||
| 
 | ||||
|     await runMigration(); | ||||
| 
 | ||||
|     expect(warnOutput.length).toBe(0); | ||||
|     expect(tree.readContent('/service.ts')) | ||||
|         .toMatch(/core';\s+@Injectable\(\)\s+export class MyService/); | ||||
|     expect(tree.readContent('/service.ts')) | ||||
|         .toMatch(/import { Inject, Injectable } from '@angular\/core';/); | ||||
|   }); | ||||
| 
 | ||||
| }); | ||||
| @ -5,6 +5,10 @@ | ||||
|     "migration-injectable-pipe": { | ||||
|       "description": "Migrates all Pipe classes so that they have an Injectable annotation", | ||||
|       "factory": "../migrations/injectable-pipe/index" | ||||
|     }, | ||||
|     "migration-missing-injectable": { | ||||
|       "description": "Migrates all declared undecorated providers with the @Injectable decorator", | ||||
|       "factory": "../migrations/missing-injectable/index" | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user