From 904a2018e0d3394ad91ffb6472f8271af7845295 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 15 Aug 2019 12:40:53 +0300 Subject: [PATCH] feat(core): add undecorated classes with decorated fields schematic (#32130) Adds a schematic that adds a `Directive` decorator to undecorated classes that have fields that use Angular decorators. PR Close #32130 --- packages/core/schematics/BUILD.bazel | 1 + packages/core/schematics/migrations.json | 5 + .../schematics/migrations/google3/BUILD.bazel | 1 + ...decoratedClassesWithDecoratedFieldsRule.ts | 56 +++++ .../BUILD.bazel | 18 ++ .../README.md | 23 ++ .../index.ts | 93 +++++++ .../utils.ts | 71 ++++++ packages/core/schematics/test/BUILD.bazel | 1 + ...ated_classes_with_decorated_fields_spec.ts | 233 ++++++++++++++++++ ...es_with_decorated_fields_migration_spec.ts | 218 ++++++++++++++++ 11 files changed, 720 insertions(+) create mode 100644 packages/core/schematics/migrations/google3/undecoratedClassesWithDecoratedFieldsRule.ts create mode 100644 packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/BUILD.bazel create mode 100644 packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/README.md create mode 100644 packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/index.ts create mode 100644 packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/utils.ts create mode 100644 packages/core/schematics/test/google3/undecorated_classes_with_decorated_fields_spec.ts create mode 100644 packages/core/schematics/test/undecorated_classes_with_decorated_fields_migration_spec.ts diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index ee525a3dbdf89..4c227c9316593 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -14,6 +14,7 @@ npm_package( "//packages/core/schematics/migrations/renderer-to-renderer2", "//packages/core/schematics/migrations/static-queries", "//packages/core/schematics/migrations/template-var-assignment", + "//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields", "//packages/core/schematics/migrations/undecorated-classes-with-di", ], ) diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 270257daa255e..e4255c52f83da 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -24,6 +24,11 @@ "version": "9-beta", "description": "Decorates undecorated base classes of directives/components that use dependency injection. Copies metadata from base classes to derived directives/components/pipes that are not decorated.", "factory": "./migrations/undecorated-classes-with-di/index" + }, + "migration-v9-undecorated-classes-with-decorated-fields": { + "version": "9-beta", + "description": "Adds an Angular decorator to undecorated classes that have decorated fields", + "factory": "./migrations/undecorated-classes-with-decorated-fields/index" } } } diff --git a/packages/core/schematics/migrations/google3/BUILD.bazel b/packages/core/schematics/migrations/google3/BUILD.bazel index faead2cddbbbc..65f1aa23d9935 100644 --- a/packages/core/schematics/migrations/google3/BUILD.bazel +++ b/packages/core/schematics/migrations/google3/BUILD.bazel @@ -11,6 +11,7 @@ ts_library( "//packages/core/schematics/migrations/renderer-to-renderer2", "//packages/core/schematics/migrations/static-queries", "//packages/core/schematics/migrations/template-var-assignment", + "//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields", "//packages/core/schematics/utils", "//packages/core/schematics/utils/tslint", "@npm//tslint", diff --git a/packages/core/schematics/migrations/google3/undecoratedClassesWithDecoratedFieldsRule.ts b/packages/core/schematics/migrations/google3/undecoratedClassesWithDecoratedFieldsRule.ts new file mode 100644 index 0000000000000..b260f63b99a00 --- /dev/null +++ b/packages/core/schematics/migrations/google3/undecoratedClassesWithDecoratedFieldsRule.ts @@ -0,0 +1,56 @@ +/** + * @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, Rules} from 'tslint'; +import * as ts from 'typescript'; + +import {FALLBACK_DECORATOR, addImport, getNamedImports, getUndecoratedClassesWithDecoratedFields, hasNamedImport} from '../undecorated-classes-with-decorated-fields/utils'; + + + +/** + * TSLint rule that adds an Angular decorator to classes that have Angular field decorators. + * https://hackmd.io/vuQfavzfRG6KUCtU7oK_EA + */ +export class Rule extends Rules.TypedRule { + applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] { + const typeChecker = program.getTypeChecker(); + const printer = ts.createPrinter(); + const classes = getUndecoratedClassesWithDecoratedFields(sourceFile, typeChecker); + + return classes.map((current, index) => { + const {classDeclaration: declaration, importDeclaration} = current; + const name = declaration.name; + + // Set the class identifier node (if available) as the failing node so IDEs don't highlight + // the entire class with red. This is similar to how errors are shown for classes in other + // cases like an interface not being implemented correctly. + const start = (name || declaration).getStart(); + const end = (name || declaration).getEnd(); + const fixes = [Replacement.appendText(declaration.getStart(), `@${FALLBACK_DECORATOR}()\n`)]; + + // If it's the first class that we're processing in this file, add `Directive` to the imports. + if (index === 0 && !hasNamedImport(importDeclaration, FALLBACK_DECORATOR)) { + const namedImports = getNamedImports(importDeclaration); + + if (namedImports) { + fixes.push(new Replacement( + namedImports.getStart(), namedImports.getWidth(), + printer.printNode( + ts.EmitHint.Unspecified, addImport(namedImports, FALLBACK_DECORATOR), + sourceFile))); + } + } + + return new RuleFailure( + sourceFile, start, end, + 'Classes with decorated fields must have an Angular decorator as well.', + 'undecorated-classes-with-decorated-fields', fixes); + }); + } +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/BUILD.bazel b/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/BUILD.bazel new file mode 100644 index 0000000000000..3bfb1119043cf --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/BUILD.bazel @@ -0,0 +1,18 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "undecorated-classes-with-decorated-fields", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/migrations/google3:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], + deps = [ + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) diff --git a/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/README.md b/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/README.md new file mode 100644 index 0000000000000..0fdec70e96489 --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/README.md @@ -0,0 +1,23 @@ +## Undecorated classes with decorated fields migration + +Automatically adds a `Directive` decorator to undecorated classes that have fields with Angular +decorators. Also adds the relevant imports, if necessary. + +#### Before +```ts +import { Input } from '@angular/core'; + +export class Base { + @Input() isActive: boolean; +} +``` + +#### After +```ts +import { Input, Directive } from '@angular/core'; + +@Directive() +export class Base { + @Input() isActive: boolean; +} +``` diff --git a/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/index.ts b/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/index.ts new file mode 100644 index 0000000000000..68f80e5d489d7 --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/index.ts @@ -0,0 +1,93 @@ +/** + * @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 {FALLBACK_DECORATOR, addImport, getNamedImports, getUndecoratedClassesWithDecoratedFields, hasNamedImport} from './utils'; + + +/** + * Migration that adds an Angular decorator to classes that have Angular field decorators. + * https://hackmd.io/vuQfavzfRG6KUCtU7oK_EA + */ +export default function(): Rule { + return (tree: Tree, context: SchematicContext) => { + const {buildPaths, testPaths} = getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + const allPaths = [...buildPaths, ...testPaths]; + const logger = context.logger; + + logger.info('------ Undecorated classes with decorated fields migration ------'); + logger.info( + 'As of Angular 9, it is no longer supported to have Angular field ' + + 'decorators on a class that does not have an Angular decorator.'); + + if (!allPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot add an Angular decorator to undecorated classes.'); + } + + for (const tsconfigPath of allPaths) { + runUndecoratedClassesMigration(tree, tsconfigPath, basePath); + } + }; +} + +function runUndecoratedClassesMigration(tree: Tree, tsconfigPath: string, basePath: string) { + const parsed = parseTsconfigFile(tsconfigPath, dirname(tsconfigPath)); + const host = ts.createCompilerHost(parsed.options, true); + + // 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. Otherwise + // if we run the migration for multiple tsconfig files which have intersecting + // source files, it can end up updating them multiple times. + host.readFile = fileName => { + const buffer = tree.read(relative(basePath, fileName)); + // Strip BOM as otherwise TSC methods (Ex: getWidth) will return an offset which + // which breaks the CLI UpdateRecorder. + // See: https://github.com/angular/angular/pull/30719 + return buffer ? buffer.toString().replace(/^\uFEFF/, '') : undefined; + }; + + const program = ts.createProgram(parsed.fileNames, parsed.options, host); + const typeChecker = program.getTypeChecker(); + const printer = ts.createPrinter(); + const sourceFiles = program.getSourceFiles().filter( + file => !file.isDeclarationFile && !program.isSourceFileFromExternalLibrary(file)); + + sourceFiles.forEach(sourceFile => { + const update = tree.beginUpdate(relative(basePath, sourceFile.fileName)); + const classes = getUndecoratedClassesWithDecoratedFields(sourceFile, typeChecker); + + classes.forEach((current, index) => { + // If it's the first class that we're processing in this file, add `Directive` to the imports. + if (index === 0 && !hasNamedImport(current.importDeclaration, FALLBACK_DECORATOR)) { + const namedImports = getNamedImports(current.importDeclaration); + + if (namedImports) { + update.remove(namedImports.getStart(), namedImports.getWidth()); + update.insertRight( + namedImports.getStart(), + printer.printNode( + ts.EmitHint.Unspecified, addImport(namedImports, FALLBACK_DECORATOR), + sourceFile)); + } + } + + // We don't need to go through the AST to insert the decorator, because the change + // is pretty basic. Also this has a better chance of preserving the user's formatting. + update.insertLeft(current.classDeclaration.getStart(), `@${FALLBACK_DECORATOR}()\n`); + }); + + tree.commitUpdate(update); + }); +} diff --git a/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/utils.ts b/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/utils.ts new file mode 100644 index 0000000000000..746f8676d68c5 --- /dev/null +++ b/packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/utils.ts @@ -0,0 +1,71 @@ +/** + * @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 {getAngularDecorators} from '../../utils/ng_decorators'; + +/** Name of the decorator that should be added to undecorated classes. */ +export const FALLBACK_DECORATOR = 'Directive'; + +/** Finds all of the undecorated classes that have decorated fields within a file. */ +export function getUndecoratedClassesWithDecoratedFields( + sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) { + const classes: UndecoratedClassWithDecoratedFields[] = []; + + sourceFile.forEachChild(function walk(node: ts.Node) { + if (ts.isClassDeclaration(node) && + (!node.decorators || !getAngularDecorators(typeChecker, node.decorators).length)) { + for (const member of node.members) { + const angularDecorators = + member.decorators && getAngularDecorators(typeChecker, member.decorators); + + if (angularDecorators && angularDecorators.length) { + classes.push( + {classDeclaration: node, importDeclaration: angularDecorators[0].importNode}); + return; + } + } + } + + node.forEachChild(walk); + }); + + return classes; +} + +/** Checks whether an import declaration has an import with a certain name. */ +export function hasNamedImport(declaration: ts.ImportDeclaration, symbolName: string): boolean { + const namedImports = getNamedImports(declaration); + + if (namedImports) { + return namedImports.elements.some(element => { + const {name, propertyName} = element; + return propertyName ? propertyName.text === symbolName : name.text === symbolName; + }); + } + + return false; +} + +/** Extracts the NamedImports node from an import declaration. */ +export function getNamedImports(declaration: ts.ImportDeclaration): ts.NamedImports|null { + const namedBindings = declaration.importClause && declaration.importClause.namedBindings; + return (namedBindings && ts.isNamedImports(namedBindings)) ? namedBindings : null; +} + +/** Adds a new import to a NamedImports node. */ +export function addImport(declaration: ts.NamedImports, symbolName: string) { + return ts.updateNamedImports(declaration, [ + ...declaration.elements, ts.createImportSpecifier(undefined, ts.createIdentifier(symbolName)) + ]); +} + +interface UndecoratedClassWithDecoratedFields { + classDeclaration: ts.ClassDeclaration; + importDeclaration: ts.ImportDeclaration; +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index eecd8449ce6a2..a73ec1a3c71ac 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -14,6 +14,7 @@ ts_library( "//packages/core/schematics/migrations/renderer-to-renderer2", "//packages/core/schematics/migrations/static-queries", "//packages/core/schematics/migrations/template-var-assignment", + "//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields", "//packages/core/schematics/migrations/undecorated-classes-with-di", "//packages/core/schematics/utils", "@npm//@angular-devkit/core", diff --git a/packages/core/schematics/test/google3/undecorated_classes_with_decorated_fields_spec.ts b/packages/core/schematics/test/google3/undecorated_classes_with_decorated_fields_spec.ts new file mode 100644 index 0000000000000..0f0687f38f239 --- /dev/null +++ b/packages/core/schematics/test/google3/undecorated_classes_with_decorated_fields_spec.ts @@ -0,0 +1,233 @@ +/** + * @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 undecorated classes with decorated fields TSLint rule', () => { + const rulesDirectory = dirname( + require.resolve('../../migrations/google3/undecoratedClassesWithDecoratedFieldsRule')); + + 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: boolean) { + const program = Linter.createProgram(join(tmpDir, 'tsconfig.json')); + const linter = new Linter({fix, rulesDirectory: [rulesDirectory]}, program); + const config = Configuration.parseConfigFile({ + rules: {'undecorated-classes-with-decorated-fields': 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 flag undecorated classes with decorated fields', () => { + writeFile('/index.ts', ` + import { Input, Directive } from '@angular/core'; + + @Directive() + export class ValidClass { + @Input() isActive: boolean; + } + + export class InvalidClass { + @Input() isActive: boolean; + } + `); + + const linter = runTSLint(false); + const failures = linter.getResult().failures.map(failure => failure.getFailure()); + + expect(failures.length).toBe(1); + expect(failures[0]) + .toBe('Classes with decorated fields must have an Angular decorator as well.'); + }); + + it(`should add an import for Directive if there isn't one already`, () => { + writeFile('/index.ts', ` + import { Input } from '@angular/core'; + + export class Base { + @Input() isActive: boolean; + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`import { Input, Directive } from '@angular/core';`); + }); + + it('should not change the imports if there is an import for Directive already', () => { + writeFile('/index.ts', ` + import { Directive, Input } from '@angular/core'; + + export class Base { + @Input() isActive: boolean; + } + + @Directive() + export class Child extends Base { + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`import { Directive, Input } from '@angular/core';`); + }); + + it('should add @Directive to undecorated classes that have @Input', () => { + writeFile('/index.ts', ` + import { Input } from '@angular/core'; + + export class Base { + @Input() isActive: boolean; + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should not change decorated classes', () => { + writeFile('/index.ts', ` + import { Input, Component, Output, EventEmitter } from '@angular/core'; + + @Component({}) + export class Base { + @Input() isActive: boolean; + } + + export class Child extends Base { + @Output() clicked = new EventEmitter(); + } + `); + + runTSLint(true); + const content = getFile('/index.ts'); + expect(content).toContain( + `import { Input, Component, Output, EventEmitter, Directive } from '@angular/core';`); + expect(content).toContain(`@Component({})\n export class Base {`); + expect(content).toContain(`@Directive()\nexport class Child extends Base {`); + }); + + it('should add @Directive to undecorated classes that have @Output', () => { + writeFile('/index.ts', ` + import { Output, EventEmitter } from '@angular/core'; + + export class Base { + @Output() clicked = new EventEmitter(); + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a host binding', () => { + writeFile('/index.ts', ` + import { HostBinding } from '@angular/core'; + + export class Base { + @HostBinding('attr.id') + get id() { + return 'id-' + Date.now(); + } + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a host listener', () => { + writeFile('/index.ts', ` + import { HostListener } from '@angular/core'; + + export class Base { + @HostListener('keydown') + handleKeydown() { + console.log('Key has been pressed'); + } + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a ViewChild query', () => { + writeFile('/index.ts', ` + import { ViewChild, ElementRef } from '@angular/core'; + + export class Base { + @ViewChild('button', { static: false }) button: ElementRef; + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a ViewChildren query', () => { + writeFile('/index.ts', ` + import { ViewChildren, ElementRef } from '@angular/core'; + + export class Base { + @ViewChildren('button') button: ElementRef; + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a ContentChild query', () => { + writeFile('/index.ts', ` + import { ContentChild, ElementRef } from '@angular/core'; + + export class Base { + @ContentChild('button', { static: false }) button: ElementRef; + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a ContentChildren query', () => { + writeFile('/index.ts', ` + import { ContentChildren, ElementRef } from '@angular/core'; + + export class Base { + @ContentChildren('button') button: ElementRef; + } + `); + + runTSLint(true); + expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + +}); diff --git a/packages/core/schematics/test/undecorated_classes_with_decorated_fields_migration_spec.ts b/packages/core/schematics/test/undecorated_classes_with_decorated_fields_migration_spec.ts new file mode 100644 index 0000000000000..7d1ff74e25aab --- /dev/null +++ b/packages/core/schematics/test/undecorated_classes_with_decorated_fields_migration_spec.ts @@ -0,0 +1,218 @@ +/** + * @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('Undecorated classes with decorated fields migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + let previousWorkingDir: string; + + beforeEach(() => { + runner = new SchematicTestRunner('test', require.resolve('../migrations.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + + writeFile('/tsconfig.json', JSON.stringify({compilerOptions: {lib: ['es2015']}})); + writeFile('/angular.json', JSON.stringify({ + projects: {t: {architect: {build: {options: {tsConfig: './tsconfig.json'}}}}} + })); + + 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); + }); + + it(`should add an import for Directive if there isn't one already`, async() => { + writeFile('/index.ts', ` + import { Input } from '@angular/core'; + + export class Base { + @Input() isActive: boolean; + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`import { Input, Directive } from '@angular/core';`); + }); + + it('should not change the imports if there is an import for Directive already', async() => { + writeFile('/index.ts', ` + import { Directive, Input } from '@angular/core'; + + export class Base { + @Input() isActive: boolean; + } + + @Directive() + export class Child extends Base { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`import { Directive, Input } from '@angular/core';`); + }); + + it('should add @Directive to undecorated classes that have @Input', async() => { + writeFile('/index.ts', ` + import { Input } from '@angular/core'; + + export class Base { + @Input() isActive: boolean; + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should not change decorated classes', async() => { + writeFile('/index.ts', ` + import { Input, Component, Output, EventEmitter } from '@angular/core'; + + @Component({}) + export class Base { + @Input() isActive: boolean; + } + + export class Child extends Base { + @Output() clicked = new EventEmitter(); + } + `); + + await runMigration(); + const content = tree.readContent('/index.ts'); + expect(content).toContain( + `import { Input, Component, Output, EventEmitter, Directive } from '@angular/core';`); + expect(content).toContain(`@Component({})\n export class Base {`); + expect(content).toContain(`@Directive()\nexport class Child extends Base {`); + }); + + it('should add @Directive to undecorated classes that have @Output', async() => { + writeFile('/index.ts', ` + import { Output, EventEmitter } from '@angular/core'; + + export class Base { + @Output() clicked = new EventEmitter(); + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a host binding', async() => { + writeFile('/index.ts', ` + import { HostBinding } from '@angular/core'; + + export class Base { + @HostBinding('attr.id') + get id() { + return 'id-' + Date.now(); + } + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a host listener', async() => { + writeFile('/index.ts', ` + import { HostListener } from '@angular/core'; + + export class Base { + @HostListener('keydown') + handleKeydown() { + console.log('Key has been pressed'); + } + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a ViewChild query', async() => { + writeFile('/index.ts', ` + import { ViewChild, ElementRef } from '@angular/core'; + + export class Base { + @ViewChild('button', { static: false }) button: ElementRef; + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a ViewChildren query', async() => { + writeFile('/index.ts', ` + import { ViewChildren, ElementRef } from '@angular/core'; + + export class Base { + @ViewChildren('button') button: ElementRef; + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a ContentChild query', async() => { + writeFile('/index.ts', ` + import { ContentChild, ElementRef } from '@angular/core'; + + export class Base { + @ContentChild('button', { static: false }) button: ElementRef; + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + it('should add @Directive to undecorated classes that have a ContentChildren query', async() => { + writeFile('/index.ts', ` + import { ContentChildren, ElementRef } from '@angular/core'; + + export class Base { + @ContentChildren('button') button: ElementRef; + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`); + }); + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + return runner + .runSchematicAsync('migration-v9-undecorated-classes-with-decorated-fields', {}, tree) + .toPromise(); + } +});