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
This commit is contained in:
Kristiyan Kostadinov 2020-10-12 18:07:34 +02:00 committed by atscott
parent 437e563507
commit 5ce71e0fbc
18 changed files with 650 additions and 58 deletions

View File

@ -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",
],
)

View File

@ -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"
}
}
}

View File

@ -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",

View File

@ -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<ts.NamedImports>(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<HelperFunction>();
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(),

View File

@ -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<ts.NamedImports>(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));
}
}

View File

@ -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<ts.NamedImports>(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 => {

View File

@ -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.

View File

@ -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,

View File

@ -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",
],
)

View File

@ -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
}));
```

View File

@ -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<ts.NamedImports>(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);
});
}

View File

@ -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.Identifier>();
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;
}

View File

@ -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",

View File

@ -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(() => {`);
});
});

View File

@ -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();
}
});

View File

@ -4,7 +4,7 @@
"noFallthroughCasesInSwitch": true,
"strict": true,
"lib": ["es2015"],
"types": [],
"types": ["jasmine"],
"baseUrl": ".",
"paths": {
"@angular/core": ["../"],

View File

@ -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<ts.ImportSpecifier>, specifierName: string): ts.ImportSpecifier|undefined {
return nodes.find(element => {
const {name, propertyName} = element;
return propertyName ? propertyName.text === specifierName : name.text === specifierName;
});
}

View File

@ -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<T extends ts.Node>(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;
}