From 5ce71e0fbc7608a621f4ba1c1e67b3a7316c9d84 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 12 Oct 2020 18:07:34 +0200 Subject: [PATCH] feat(core): add automated migration to replace async with waitForAsync (#39212) Adds a migration that finds all imports and calls to the deprecated `async` function from `@angular/core/testing` and replaces them with `waitForAsync`. These changes also move a bit of code out of the `Renderer2` migration so that it can be reused. PR Close #39212 --- packages/core/schematics/BUILD.bazel | 1 + packages/core/schematics/migrations.json | 5 + .../schematics/migrations/google3/BUILD.bazel | 1 + .../google3/rendererToRenderer2Rule.ts | 20 +- .../migrations/google3/waitForAsyncRule.ts | 65 +++++++ .../migrations/renderer-to-renderer2/index.ts | 16 +- .../renderer-to-renderer2/migration.ts | 21 -- .../migrations/renderer-to-renderer2/util.ts | 14 -- .../migrations/wait-for-async/BUILD.bazel | 18 ++ .../migrations/wait-for-async/README.md | 21 ++ .../migrations/wait-for-async/index.ts | 92 +++++++++ .../migrations/wait-for-async/util.ts | 30 +++ packages/core/schematics/test/BUILD.bazel | 1 + .../test/google3/wait_for_async_spec.ts | 181 ++++++++++++++++++ .../test/wait_for_async_migration_spec.ts | 161 ++++++++++++++++ packages/core/schematics/tsconfig.json | 2 +- .../schematics/utils/typescript/imports.ts | 45 ++++- .../core/schematics/utils/typescript/nodes.ts | 14 ++ 18 files changed, 650 insertions(+), 58 deletions(-) create mode 100644 packages/core/schematics/migrations/google3/waitForAsyncRule.ts create mode 100644 packages/core/schematics/migrations/wait-for-async/BUILD.bazel create mode 100644 packages/core/schematics/migrations/wait-for-async/README.md create mode 100644 packages/core/schematics/migrations/wait-for-async/index.ts create mode 100644 packages/core/schematics/migrations/wait-for-async/util.ts create mode 100644 packages/core/schematics/test/google3/wait_for_async_spec.ts create mode 100644 packages/core/schematics/test/wait_for_async_migration_spec.ts diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index bcf0d6ecb3..2817d90c8b 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -23,5 +23,6 @@ pkg_npm( "//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/migrations/wait-for-async", ], ) diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 2ba3e20101..60c47e1e6a 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -64,6 +64,11 @@ "version": "11.0.0-beta", "description": "ViewEncapsulation.Native has been removed as of Angular version 11. This migration replaces any usages with ViewEncapsulation.ShadowDom.", "factory": "./migrations/native-view-encapsulation/index" + }, + "migration-v11-wait-for-async": { + "version": "11.0.0-beta", + "description": "`async` to `waitForAsync` migration. The `async` testing function has been renamed to `waitForAsync` to avoid confusion with the native `async` keyword.", + "factory": "./migrations/wait-for-async/index" } } } diff --git a/packages/core/schematics/migrations/google3/BUILD.bazel b/packages/core/schematics/migrations/google3/BUILD.bazel index 217e7bc90d..651de6e76e 100644 --- a/packages/core/schematics/migrations/google3/BUILD.bazel +++ b/packages/core/schematics/migrations/google3/BUILD.bazel @@ -17,6 +17,7 @@ ts_library( "//packages/core/schematics/migrations/template-var-assignment", "//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields", "//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields/google3", + "//packages/core/schematics/migrations/wait-for-async", "//packages/core/schematics/utils", "//packages/core/schematics/utils/tslint", "@npm//tslint", diff --git a/packages/core/schematics/migrations/google3/rendererToRenderer2Rule.ts b/packages/core/schematics/migrations/google3/rendererToRenderer2Rule.ts index 4ec020fbf5..0155066eb7 100644 --- a/packages/core/schematics/migrations/google3/rendererToRenderer2Rule.ts +++ b/packages/core/schematics/migrations/google3/rendererToRenderer2Rule.ts @@ -8,11 +8,12 @@ import {Replacement, RuleFailure, Rules} from 'tslint'; import * as ts from 'typescript'; -import {getImportSpecifier} from '../../utils/typescript/imports'; +import {getImportSpecifier, replaceImport} from '../../utils/typescript/imports'; +import {closestNode} from '../../utils/typescript/nodes'; import {getHelper, HelperFunction} from '../renderer-to-renderer2/helpers'; -import {migrateExpression, replaceImport} from '../renderer-to-renderer2/migration'; -import {findRendererReferences, getNamedImports} from '../renderer-to-renderer2/util'; +import {migrateExpression} from '../renderer-to-renderer2/migration'; +import {findRendererReferences} from '../renderer-to-renderer2/util'; /** * TSLint rule that migrates from `Renderer` to `Renderer2`. More information on how it works: @@ -24,8 +25,9 @@ export class Rule extends Rules.TypedRule { const printer = ts.createPrinter(); const failures: RuleFailure[] = []; const rendererImportSpecifier = getImportSpecifier(sourceFile, '@angular/core', 'Renderer'); - const rendererImport = - rendererImportSpecifier ? getNamedImports(rendererImportSpecifier) : null; + const rendererImport = rendererImportSpecifier ? + closestNode(rendererImportSpecifier, ts.SyntaxKind.NamedImports) : + null; // If there are no imports for the `Renderer`, we can exit early. if (!rendererImportSpecifier || !rendererImport) { @@ -36,8 +38,7 @@ export class Rule extends Rules.TypedRule { findRendererReferences(sourceFile, typeChecker, rendererImportSpecifier); const helpersToAdd = new Set(); - failures.push( - this._getNamedImportsFailure(rendererImport, rendererImportSpecifier, sourceFile, printer)); + failures.push(this._getNamedImportsFailure(rendererImport, sourceFile, printer)); typedNodes.forEach(node => failures.push(this._getTypedNodeFailure(node, sourceFile))); forwardRefs.forEach(node => failures.push(this._getIdentifierNodeFailure(node, sourceFile))); @@ -65,10 +66,9 @@ export class Rule extends Rules.TypedRule { /** Gets a failure for an import of the Renderer. */ private _getNamedImportsFailure( - node: ts.NamedImports, importSpecifier: ts.ImportSpecifier, sourceFile: ts.SourceFile, - printer: ts.Printer): RuleFailure { + node: ts.NamedImports, sourceFile: ts.SourceFile, printer: ts.Printer): RuleFailure { const replacementText = printer.printNode( - ts.EmitHint.Unspecified, replaceImport(node, importSpecifier, 'Renderer2'), sourceFile); + ts.EmitHint.Unspecified, replaceImport(node, 'Renderer', 'Renderer2'), sourceFile); return new RuleFailure( sourceFile, node.getStart(), node.getEnd(), diff --git a/packages/core/schematics/migrations/google3/waitForAsyncRule.ts b/packages/core/schematics/migrations/google3/waitForAsyncRule.ts new file mode 100644 index 0000000000..44f374d60d --- /dev/null +++ b/packages/core/schematics/migrations/google3/waitForAsyncRule.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Replacement, RuleFailure, Rules} from 'tslint'; +import * as ts from 'typescript'; + +import {findAsyncReferences} from '../../migrations/wait-for-async/util'; +import {getImportSpecifier, replaceImport} from '../../utils/typescript/imports'; +import {closestNode} from '../../utils/typescript/nodes'; + +/** Name of the deprecated function that we're removing. */ +const deprecatedFunction = 'async'; + +/** Name of the function that will replace the deprecated one. */ +const newFunction = 'waitForAsync'; + +/** TSLint rule that migrates from `async` to `waitForAsync`. */ +export class Rule extends Rules.TypedRule { + applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] { + const failures: RuleFailure[] = []; + const asyncImportSpecifier = + getImportSpecifier(sourceFile, '@angular/core/testing', deprecatedFunction); + const asyncImport = asyncImportSpecifier ? + closestNode(asyncImportSpecifier, ts.SyntaxKind.NamedImports) : + null; + + // If there are no imports of `async`, we can exit early. + if (asyncImportSpecifier && asyncImport) { + const typeChecker = program.getTypeChecker(); + const printer = ts.createPrinter(); + failures.push(this._getNamedImportsFailure(asyncImport, sourceFile, printer)); + findAsyncReferences(sourceFile, typeChecker, asyncImportSpecifier) + .forEach(node => failures.push(this._getIdentifierNodeFailure(node, sourceFile))); + } + + return failures; + } + + /** Gets a failure for an import of the `async` function. */ + private _getNamedImportsFailure( + node: ts.NamedImports, sourceFile: ts.SourceFile, printer: ts.Printer): RuleFailure { + const replacementText = printer.printNode( + ts.EmitHint.Unspecified, replaceImport(node, deprecatedFunction, newFunction), sourceFile); + + return new RuleFailure( + sourceFile, node.getStart(), node.getEnd(), + `Imports of the deprecated ${deprecatedFunction} function are not allowed. Use ${ + newFunction} instead.`, + this.ruleName, new Replacement(node.getStart(), node.getWidth(), replacementText)); + } + + /** Gets a failure for an identifier node. */ + private _getIdentifierNodeFailure(node: ts.Identifier, sourceFile: ts.SourceFile): RuleFailure { + return new RuleFailure( + sourceFile, node.getStart(), node.getEnd(), + `References to the deprecated ${deprecatedFunction} function are not allowed. Use ${ + newFunction} instead.`, + this.ruleName, new Replacement(node.getStart(), node.getWidth(), newFunction)); + } +} diff --git a/packages/core/schematics/migrations/renderer-to-renderer2/index.ts b/packages/core/schematics/migrations/renderer-to-renderer2/index.ts index b40d044b04..79bf08a4cf 100644 --- a/packages/core/schematics/migrations/renderer-to-renderer2/index.ts +++ b/packages/core/schematics/migrations/renderer-to-renderer2/index.ts @@ -12,11 +12,12 @@ import * as ts from 'typescript'; import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; import {createMigrationProgram} from '../../utils/typescript/compiler_host'; -import {getImportSpecifier} from '../../utils/typescript/imports'; +import {getImportSpecifier, replaceImport} from '../../utils/typescript/imports'; +import {closestNode} from '../../utils/typescript/nodes'; import {getHelper, HelperFunction} from './helpers'; -import {migrateExpression, replaceImport} from './migration'; -import {findRendererReferences, getNamedImports} from './util'; +import {migrateExpression} from './migration'; +import {findRendererReferences} from './util'; const MODULE_AUGMENTATION_FILENAME = 'ɵɵRENDERER_MIGRATION_CORE_AUGMENTATION.d.ts'; @@ -63,8 +64,9 @@ function runRendererToRenderer2Migration(tree: Tree, tsconfigPath: string, baseP sourceFiles.forEach(sourceFile => { const rendererImportSpecifier = getImportSpecifier(sourceFile, '@angular/core', 'Renderer'); - const rendererImport = - rendererImportSpecifier ? getNamedImports(rendererImportSpecifier) : null; + const rendererImport = rendererImportSpecifier ? + closestNode(rendererImportSpecifier, ts.SyntaxKind.NamedImports) : + null; // If there are no imports for the `Renderer`, we can exit early. if (!rendererImportSpecifier || !rendererImport) { @@ -81,8 +83,8 @@ function runRendererToRenderer2Migration(tree: Tree, tsconfigPath: string, baseP update.insertRight( rendererImport.getStart(), printer.printNode( - ts.EmitHint.Unspecified, - replaceImport(rendererImport, rendererImportSpecifier, 'Renderer2'), sourceFile)); + ts.EmitHint.Unspecified, replaceImport(rendererImport, 'Renderer', 'Renderer2'), + sourceFile)); // Change the method parameter and property types to `Renderer2`. typedNodes.forEach(node => { diff --git a/packages/core/schematics/migrations/renderer-to-renderer2/migration.ts b/packages/core/schematics/migrations/renderer-to-renderer2/migration.ts index 49587c4b83..2711309f4a 100644 --- a/packages/core/schematics/migrations/renderer-to-renderer2/migration.ts +++ b/packages/core/schematics/migrations/renderer-to-renderer2/migration.ts @@ -13,27 +13,6 @@ import {HelperFunction} from './helpers'; /** A call expression that is based on a property access. */ type PropertyAccessCallExpression = ts.CallExpression&{expression: ts.PropertyAccessExpression}; -/** Replaces an import inside an import statement with a different one. */ -export function replaceImport( - node: ts.NamedImports, existingImport: ts.ImportSpecifier, newImportName: string) { - const isAlreadyImported = node.elements.find(element => { - const {name, propertyName} = element; - return propertyName ? propertyName.text === newImportName : name.text === newImportName; - }); - - if (isAlreadyImported) { - return node; - } - - return ts.updateNamedImports(node, [ - ...node.elements.filter(current => current !== existingImport), - // Create a new import while trying to preserve the alias of the old one. - ts.createImportSpecifier( - existingImport.propertyName ? ts.createIdentifier(newImportName) : undefined, - existingImport.propertyName ? existingImport.name : ts.createIdentifier(newImportName)) - ]); -} - /** * Migrates a function call expression from `Renderer` to `Renderer2`. * Returns null if the expression should be dropped. diff --git a/packages/core/schematics/migrations/renderer-to-renderer2/util.ts b/packages/core/schematics/migrations/renderer-to-renderer2/util.ts index 6d22c4fa01..3987cb64d5 100644 --- a/packages/core/schematics/migrations/renderer-to-renderer2/util.ts +++ b/packages/core/schematics/migrations/renderer-to-renderer2/util.ts @@ -54,20 +54,6 @@ export function findRendererReferences( return {typedNodes, methodCalls, forwardRefs}; } -/** Gets the closest `NamedImports` to an `ImportSpecifier`. */ -export function getNamedImports(specifier: ts.ImportSpecifier): ts.NamedImports|null { - let current: ts.Node = specifier; - - while (current && !ts.isSourceFile(current)) { - if (ts.isNamedImports(current)) { - return current; - } - current = current.parent; - } - - return null; -} - /** Finds the identifier referring to the `Renderer` inside a `forwardRef` call expression. */ function findRendererIdentifierInForwardRef( typeChecker: ts.TypeChecker, node: ts.CallExpression, diff --git a/packages/core/schematics/migrations/wait-for-async/BUILD.bazel b/packages/core/schematics/migrations/wait-for-async/BUILD.bazel new file mode 100644 index 0000000000..18cf7800c8 --- /dev/null +++ b/packages/core/schematics/migrations/wait-for-async/BUILD.bazel @@ -0,0 +1,18 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "wait-for-async", + 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/wait-for-async/README.md b/packages/core/schematics/migrations/wait-for-async/README.md new file mode 100644 index 0000000000..468fb93496 --- /dev/null +++ b/packages/core/schematics/migrations/wait-for-async/README.md @@ -0,0 +1,21 @@ +## async -> waitForAsync migration + +Automatically migrates from `async` to `waitForAsync` by changing function calls and renaming imports. + +#### Before +```ts +import { async } from '@angular/core/testing'; + +it('should work', async(() => { + // async testing logic +})); +``` + +#### After +```ts +import { waitForAsync } from '@angular/core/testing'; + +it('should work', waitForAsync(() => { + // async testing logic +})); +``` diff --git a/packages/core/schematics/migrations/wait-for-async/index.ts b/packages/core/schematics/migrations/wait-for-async/index.ts new file mode 100644 index 0000000000..28254c5f7a --- /dev/null +++ b/packages/core/schematics/migrations/wait-for-async/index.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Rule, SchematicsException, Tree} from '@angular-devkit/schematics'; +import {relative} from 'path'; +import * as ts from 'typescript'; + +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; +import {createMigrationProgram} from '../../utils/typescript/compiler_host'; +import {getImportSpecifier, replaceImport} from '../../utils/typescript/imports'; +import {closestNode} from '../../utils/typescript/nodes'; + +import {findAsyncReferences} from './util'; + +const MODULE_AUGMENTATION_FILENAME = 'ɵɵASYNC_MIGRATION_CORE_AUGMENTATION.d.ts'; + +/** Migration that switches from `async` to `waitForAsync`. */ +export default function(): Rule { + return (tree: Tree) => { + const {buildPaths, testPaths} = getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + const allPaths = [...buildPaths, ...testPaths]; + + if (!allPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot migrate async usages to waitForAsync.'); + } + + for (const tsconfigPath of allPaths) { + runWaitForAsyncMigration(tree, tsconfigPath, basePath); + } + }; +} + +function runWaitForAsyncMigration(tree: Tree, tsconfigPath: string, basePath: string) { + const {program} = createMigrationProgram(tree, tsconfigPath, basePath, fileName => { + // In case the module augmentation file has been requested, we return a source file that + // augments "@angular/core/testing" to include a named export called "async". This ensures that + // we can rely on the type checker for this migration after `async` has been removed. + if (fileName === MODULE_AUGMENTATION_FILENAME) { + return ` + import '@angular/core/testing'; + declare module "@angular/core/testing" { + function async(fn: Function): any; + } + `; + } + return null; + }, [MODULE_AUGMENTATION_FILENAME]); + const typeChecker = program.getTypeChecker(); + const printer = ts.createPrinter(); + const sourceFiles = program.getSourceFiles().filter( + f => !f.isDeclarationFile && !program.isSourceFileFromExternalLibrary(f)); + const deprecatedFunction = 'async'; + const newFunction = 'waitForAsync'; + + sourceFiles.forEach(sourceFile => { + const asyncImportSpecifier = + getImportSpecifier(sourceFile, '@angular/core/testing', deprecatedFunction); + const asyncImport = asyncImportSpecifier ? + closestNode(asyncImportSpecifier, ts.SyntaxKind.NamedImports) : + null; + + // If there are no imports for `async`, we can exit early. + if (!asyncImportSpecifier || !asyncImport) { + return; + } + + const update = tree.beginUpdate(relative(basePath, sourceFile.fileName)); + + // Change the `async` import to `waitForAsync`. + update.remove(asyncImport.getStart(), asyncImport.getWidth()); + update.insertRight( + asyncImport.getStart(), + printer.printNode( + ts.EmitHint.Unspecified, replaceImport(asyncImport, deprecatedFunction, newFunction), + sourceFile)); + + // Change `async` calls to `waitForAsync`. + findAsyncReferences(sourceFile, typeChecker, asyncImportSpecifier).forEach(node => { + update.remove(node.getStart(), node.getWidth()); + update.insertRight(node.getStart(), newFunction); + }); + + tree.commitUpdate(update); + }); +} diff --git a/packages/core/schematics/migrations/wait-for-async/util.ts b/packages/core/schematics/migrations/wait-for-async/util.ts new file mode 100644 index 0000000000..bea02d9863 --- /dev/null +++ b/packages/core/schematics/migrations/wait-for-async/util.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as ts from 'typescript'; + +import {isReferenceToImport} from '../../utils/typescript/symbol'; + +/** Finds calls to the `async` function. */ +export function findAsyncReferences( + sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker, + asyncImportSpecifier: ts.ImportSpecifier) { + const results = new Set(); + + ts.forEachChild(sourceFile, function visitNode(node: ts.Node) { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && + node.expression.text === 'async' && + isReferenceToImport(typeChecker, node.expression, asyncImportSpecifier)) { + results.add(node.expression); + } + + ts.forEachChild(node, visitNode); + }); + + return results; +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index 0a8fd5d13d..e03a2c20ff 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -21,6 +21,7 @@ ts_library( "//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/migrations/wait-for-async", "//packages/core/schematics/utils", "@npm//@angular-devkit/core", "@npm//@angular-devkit/schematics", diff --git a/packages/core/schematics/test/google3/wait_for_async_spec.ts b/packages/core/schematics/test/google3/wait_for_async_spec.ts new file mode 100644 index 0000000000..d68f202988 --- /dev/null +++ b/packages/core/schematics/test/google3/wait_for_async_spec.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {readFileSync, writeFileSync} from 'fs'; +import {dirname, join} from 'path'; +import * as shx from 'shelljs'; +import {Configuration, Linter} from 'tslint'; + +describe('Google3 waitForAsync TSLint rule', () => { + const rulesDirectory = dirname(require.resolve('../../migrations/google3/waitForAsyncRule')); + + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(process.env['TEST_TMPDIR']!, 'google3-test'); + shx.mkdir('-p', tmpDir); + + // We need to declare the Angular symbols we're testing for, otherwise type checking won't work. + writeFile('testing.d.ts', ` + export declare function async(fn: Function): any; + `); + + writeFile('tsconfig.json', JSON.stringify({ + compilerOptions: { + module: 'es2015', + baseUrl: './', + paths: { + '@angular/core/testing': ['testing.d.ts'], + } + }, + })); + }); + + 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: {'wait-for-async': 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 async imports and usages', () => { + writeFile('/index.ts', ` + import { async, inject } from '@angular/core/testing'; + + it('should work', async(() => { + expect(inject('foo')).toBe('foo'); + })); + + it('should also work', async(() => { + expect(inject('bar')).toBe('bar'); + })); + `); + + const linter = runTSLint(false); + const failures = linter.getResult().failures.map(failure => failure.getFailure()); + expect(failures.length).toBe(3); + expect(failures[0]).toMatch(/Imports of the deprecated async function are not allowed/); + expect(failures[1]).toMatch(/References to the deprecated async function are not allowed/); + expect(failures[2]).toMatch(/References to the deprecated async function are not allowed/); + }); + + it('should change async imports to waitForAsync', () => { + writeFile('/index.ts', ` + import { async, inject } from '@angular/core/testing'; + + it('should work', async(() => { + expect(inject('foo')).toBe('foo'); + })); + `); + + runTSLint(true); + expect(getFile('/index.ts')) + .toContain(`import { inject, waitForAsync } from '@angular/core/testing';`); + }); + + it('should change aliased async imports to waitForAsync', () => { + writeFile('/index.ts', ` + import { async as renamedAsync, inject } from '@angular/core/testing'; + + it('should work', renamedAsync(() => { + expect(inject('foo')).toBe('foo'); + })); + `); + + runTSLint(true); + expect(getFile('/index.ts')) + .toContain(`import { inject, waitForAsync as renamedAsync } from '@angular/core/testing';`); + }); + + it('should not change async imports if they are not from @angular/core/testing', () => { + writeFile('/index.ts', ` + import { inject } from '@angular/core/testing'; + import { async } from './my-test-library'; + + it('should work', async(() => { + expect(inject('foo')).toBe('foo'); + })); + `); + + runTSLint(true); + const content = getFile('/index.ts'); + expect(content).toContain(`import { inject } from '@angular/core/testing';`); + expect(content).toContain(`import { async } from './my-test-library';`); + }); + + it('should not change imports if waitForAsync was already imported', () => { + writeFile('/index.ts', ` + import { async, inject, waitForAsync } from '@angular/core/testing'; + + it('should work', async(() => { + expect(inject('foo')).toBe('foo'); + })); + + it('should also work', waitForAsync(() => { + expect(inject('bar')).toBe('bar'); + })); + `); + + runTSLint(true); + expect(getFile('/index.ts')) + .toContain(`import { async, inject, waitForAsync } from '@angular/core/testing';`); + }); + + it('should change calls from `async` to `waitForAsync`', () => { + writeFile('/index.ts', ` + import { async, inject } from '@angular/core/testing'; + + it('should work', async(() => { + expect(inject('foo')).toBe('foo'); + })); + + it('should also work', async(() => { + expect(inject('bar')).toBe('bar'); + })); + `); + + runTSLint(true); + + const content = getFile('/index.ts'); + expect(content).toContain(`import { inject, waitForAsync } from '@angular/core/testing';`); + expect(content).toContain(`it('should work', waitForAsync(() => {`); + expect(content).toContain(`it('should also work', waitForAsync(() => {`); + }); + + it('should not change aliased calls', () => { + writeFile('/index.ts', ` + import { async as renamedAsync, inject } from '@angular/core/testing'; + + it('should work', renamedAsync(() => { + expect(inject('foo')).toBe('foo'); + })); + `); + + runTSLint(true); + + const content = getFile('/index.ts'); + expect(content).toContain( + `import { inject, waitForAsync as renamedAsync } from '@angular/core/testing';`); + expect(content).toContain(`it('should work', renamedAsync(() => {`); + }); +}); diff --git a/packages/core/schematics/test/wait_for_async_migration_spec.ts b/packages/core/schematics/test/wait_for_async_migration_spec.ts new file mode 100644 index 0000000000..ef0a47fc68 --- /dev/null +++ b/packages/core/schematics/test/wait_for_async_migration_spec.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.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('waitForAsync 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'], + strictNullChecks: true, + }, + })); + writeFile('/angular.json', JSON.stringify({ + projects: {t: {architect: {build: {options: {tsConfig: './tsconfig.json'}}}}} + })); + // We need to declare the Angular symbols we're testing for, otherwise type checking won't work. + writeFile('/node_modules/@angular/core/testing/index.d.ts', ` + export declare function async(fn: Function): any; + `); + + 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 change async imports to waitForAsync', async () => { + writeFile('/index.ts', ` + import { async, inject } from '@angular/core/testing'; + + it('should work', async(() => { + expect(inject('foo')).toBe('foo'); + })); + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`import { inject, waitForAsync } from '@angular/core/testing';`); + }); + + it('should change aliased async imports to waitForAsync', async () => { + writeFile('/index.ts', ` + import { async as renamedAsync, inject } from '@angular/core/testing'; + + it('should work', renamedAsync(() => { + expect(inject('foo')).toBe('foo'); + })); + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`import { inject, waitForAsync as renamedAsync } from '@angular/core/testing';`); + }); + + it('should not change async imports if they are not from @angular/core/testing', async () => { + writeFile('/index.ts', ` + import { inject } from '@angular/core/testing'; + import { async } from './my-test-library'; + + it('should work', async(() => { + expect(inject('foo')).toBe('foo'); + })); + `); + + await runMigration(); + const content = tree.readContent('/index.ts'); + expect(content).toContain(`import { inject } from '@angular/core/testing';`); + expect(content).toContain(`import { async } from './my-test-library';`); + }); + + it('should not change imports if waitForAsync was already imported', async () => { + writeFile('/index.ts', ` + import { async, inject, waitForAsync } from '@angular/core/testing'; + + it('should work', async(() => { + expect(inject('foo')).toBe('foo'); + })); + + it('should also work', waitForAsync(() => { + expect(inject('bar')).toBe('bar'); + })); + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`import { async, inject, waitForAsync } from '@angular/core/testing';`); + }); + + it('should change calls from `async` to `waitForAsync`', async () => { + writeFile('/index.ts', ` + import { async, inject } from '@angular/core/testing'; + + it('should work', async(() => { + expect(inject('foo')).toBe('foo'); + })); + + it('should also work', async(() => { + expect(inject('bar')).toBe('bar'); + })); + `); + + await runMigration(); + + const content = tree.readContent('/index.ts'); + expect(content).toContain(`import { inject, waitForAsync } from '@angular/core/testing';`); + expect(content).toContain(`it('should work', waitForAsync(() => {`); + expect(content).toContain(`it('should also work', waitForAsync(() => {`); + }); + + it('should not change aliased calls', async () => { + writeFile('/index.ts', ` + import { async as renamedAsync, inject } from '@angular/core/testing'; + + it('should work', renamedAsync(() => { + expect(inject('foo')).toBe('foo'); + })); + `); + + await runMigration(); + + const content = tree.readContent('/index.ts'); + expect(content).toContain( + `import { inject, waitForAsync as renamedAsync } from '@angular/core/testing';`); + expect(content).toContain(`it('should work', renamedAsync(() => {`); + }); + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + return runner.runSchematicAsync('migration-v11-wait-for-async', {}, tree).toPromise(); + } +}); diff --git a/packages/core/schematics/tsconfig.json b/packages/core/schematics/tsconfig.json index 3901167198..ba11c35a3d 100644 --- a/packages/core/schematics/tsconfig.json +++ b/packages/core/schematics/tsconfig.json @@ -4,7 +4,7 @@ "noFallthroughCasesInSwitch": true, "strict": true, "lib": ["es2015"], - "types": [], + "types": ["jasmine"], "baseUrl": ".", "paths": { "@angular/core": ["../"], diff --git a/packages/core/schematics/utils/typescript/imports.ts b/packages/core/schematics/utils/typescript/imports.ts index 152c21d292..ccfe75aadd 100644 --- a/packages/core/schematics/utils/typescript/imports.ts +++ b/packages/core/schematics/utils/typescript/imports.ts @@ -68,11 +68,7 @@ export function getImportSpecifier( node.moduleSpecifier.text === moduleName) { const namedBindings = node.importClause && node.importClause.namedBindings; if (namedBindings && ts.isNamedImports(namedBindings)) { - const match = namedBindings.elements.find(element => { - const {name, propertyName} = element; - return propertyName ? propertyName.text === specifierName : name.text === specifierName; - }); - + const match = findImportSpecifier(namedBindings.elements, specifierName); if (match) { return match; } @@ -82,3 +78,42 @@ export function getImportSpecifier( return null; } + + +/** + * Replaces an import inside a named imports node with a different one. + * @param node Node that contains the imports. + * @param existingImport Import that should be replaced. + * @param newImportName Import that should be inserted. + */ +export function replaceImport( + node: ts.NamedImports, existingImport: string, newImportName: string) { + const isAlreadyImported = findImportSpecifier(node.elements, newImportName); + if (isAlreadyImported) { + return node; + } + + const existingImportNode = findImportSpecifier(node.elements, existingImport); + if (!existingImportNode) { + return node; + } + + return ts.updateNamedImports(node, [ + ...node.elements.filter(current => current !== existingImportNode), + // Create a new import while trying to preserve the alias of the old one. + ts.createImportSpecifier( + existingImportNode.propertyName ? ts.createIdentifier(newImportName) : undefined, + existingImportNode.propertyName ? existingImportNode.name : + ts.createIdentifier(newImportName)) + ]); +} + + +/** Finds an import specifier with a particular name. */ +function findImportSpecifier( + nodes: ts.NodeArray, specifierName: string): ts.ImportSpecifier|undefined { + return nodes.find(element => { + const {name, propertyName} = element; + return propertyName ? propertyName.text === specifierName : name.text === specifierName; + }); +} diff --git a/packages/core/schematics/utils/typescript/nodes.ts b/packages/core/schematics/utils/typescript/nodes.ts index 69695345a3..f6b5a3eb0a 100644 --- a/packages/core/schematics/utils/typescript/nodes.ts +++ b/packages/core/schematics/utils/typescript/nodes.ts @@ -12,3 +12,17 @@ import * as ts from 'typescript'; export function hasModifier(node: ts.Node, modifierKind: ts.SyntaxKind) { return !!node.modifiers && node.modifiers.some(m => m.kind === modifierKind); } + +/** Find the closest parent node of a particular kind. */ +export function closestNode(node: ts.Node, kind: ts.SyntaxKind): T|null { + let current: ts.Node = node; + + while (current && !ts.isSourceFile(current)) { + if (current.kind === kind) { + return current as T; + } + current = current.parent; + } + + return null; +}