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 = [ |     deps = [ | ||||||
|         "//packages/core/schematics/migrations/injectable-pipe", |         "//packages/core/schematics/migrations/injectable-pipe", | ||||||
|         "//packages/core/schematics/migrations/injectable-pipe/google3", |         "//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/move-document", | ||||||
|         "//packages/core/schematics/migrations/renderer-to-renderer2", |         "//packages/core/schematics/migrations/renderer-to-renderer2", | ||||||
|         "//packages/core/schematics/migrations/renderer-to-renderer2/google3", |         "//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": { |     "migration-injectable-pipe": { | ||||||
|       "description": "Migrates all Pipe classes so that they have an Injectable annotation", |       "description": "Migrates all Pipe classes so that they have an Injectable annotation", | ||||||
|       "factory": "../migrations/injectable-pipe/index" |       "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