From 0ec7043490b7ed688137ac4b8d8e7db2d8a5829e Mon Sep 17 00:00:00 2001 From: Adam Plumer Date: Sat, 12 Oct 2019 23:25:58 -0500 Subject: [PATCH] feat(core): add initialNavigation schematic (#36926) Add a schematic to update users to the new v11 `initialNavigation` options for `RouterModule`. This replaces the deprecated/removed `true`, `false`, `legacy_disabled`, and `legacy_enabled` options with the newer `enabledBlocking` and `enabledNonBlocking` options. PR Close #36926 --- packages/core/schematics/BUILD.bazel | 1 + packages/core/schematics/migrations.json | 5 + .../schematics/migrations/google3/BUILD.bazel | 2 + .../google3/initialNavigationRule.ts | 55 ++++ .../migrations/initial-navigation/BUILD.bazel | 19 ++ .../migrations/initial-navigation/README.md | 43 ++++ .../initial-navigation/collector.ts | 116 +++++++++ .../initial-navigation/google3/BUILD.bazel | 13 + .../google3/tslint_update_recorder.ts | 26 ++ .../migrations/initial-navigation/index.ts | 73 ++++++ .../initial-navigation/transform.ts | 75 ++++++ .../initial-navigation/update_recorder.ts | 19 ++ .../migrations/initial-navigation/util.ts | 33 +++ .../module-with-providers/transform.ts | 2 +- packages/core/schematics/test/BUILD.bazel | 1 + .../google3/initial_navigation_rule_spec.ts | 211 ++++++++++++++++ .../test/initial_navigation_migration_spec.ts | 239 ++++++++++++++++++ 17 files changed, 932 insertions(+), 1 deletion(-) create mode 100644 packages/core/schematics/migrations/google3/initialNavigationRule.ts create mode 100644 packages/core/schematics/migrations/initial-navigation/BUILD.bazel create mode 100644 packages/core/schematics/migrations/initial-navigation/README.md create mode 100644 packages/core/schematics/migrations/initial-navigation/collector.ts create mode 100644 packages/core/schematics/migrations/initial-navigation/google3/BUILD.bazel create mode 100644 packages/core/schematics/migrations/initial-navigation/google3/tslint_update_recorder.ts create mode 100644 packages/core/schematics/migrations/initial-navigation/index.ts create mode 100644 packages/core/schematics/migrations/initial-navigation/transform.ts create mode 100644 packages/core/schematics/migrations/initial-navigation/update_recorder.ts create mode 100644 packages/core/schematics/migrations/initial-navigation/util.ts create mode 100644 packages/core/schematics/test/google3/initial_navigation_rule_spec.ts create mode 100644 packages/core/schematics/test/initial_navigation_migration_spec.ts diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index c58ce9ba1c..b68ceb4eb9 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -12,6 +12,7 @@ pkg_npm( deps = [ "//packages/core/schematics/migrations/abstract-control-parent", "//packages/core/schematics/migrations/dynamic-queries", + "//packages/core/schematics/migrations/initial-navigation", "//packages/core/schematics/migrations/missing-injectable", "//packages/core/schematics/migrations/module-with-providers", "//packages/core/schematics/migrations/move-document", diff --git a/packages/core/schematics/migrations.json b/packages/core/schematics/migrations.json index 5f5a9f3e70..818a1535a3 100644 --- a/packages/core/schematics/migrations.json +++ b/packages/core/schematics/migrations.json @@ -74,6 +74,11 @@ "version": "11.0.0-beta", "description": "NavigationExtras.preserveQueryParams has been removed as of Angular version 11. This migration replaces any usages with the appropriate assignment of the queryParamsHandler key.", "factory": "./migrations/router-preserve-query-params/index" + }, + "migration-v11-router-initial-navigation-options": { + "version": "11.0.0-beta", + "description": "Updates the `initialNavigation` property for `RouterModule.forRoot`.", + "factory": "./migrations/initial-navigation/index" } } } diff --git a/packages/core/schematics/migrations/google3/BUILD.bazel b/packages/core/schematics/migrations/google3/BUILD.bazel index 651de6e76e..0eecceb175 100644 --- a/packages/core/schematics/migrations/google3/BUILD.bazel +++ b/packages/core/schematics/migrations/google3/BUILD.bazel @@ -7,6 +7,8 @@ ts_library( visibility = ["//packages/core/schematics/test/google3:__pkg__"], deps = [ "//packages/core/schematics/migrations/dynamic-queries", + "//packages/core/schematics/migrations/initial-navigation", + "//packages/core/schematics/migrations/initial-navigation/google3", "//packages/core/schematics/migrations/missing-injectable", "//packages/core/schematics/migrations/missing-injectable/google3", "//packages/core/schematics/migrations/navigation-extras-omissions", diff --git a/packages/core/schematics/migrations/google3/initialNavigationRule.ts b/packages/core/schematics/migrations/google3/initialNavigationRule.ts new file mode 100644 index 0000000000..41a2750c97 --- /dev/null +++ b/packages/core/schematics/migrations/google3/initialNavigationRule.ts @@ -0,0 +1,55 @@ +/** + * @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 {RuleFailure, Rules} from 'tslint'; +import * as ts from 'typescript'; + +import {InitialNavigationCollector} from '../initial-navigation/collector'; +import {TslintUpdateRecorder} from '../initial-navigation/google3/tslint_update_recorder'; +import {InitialNavigationTransform} from '../initial-navigation/transform'; + + + +/** + * TSLint rule that updates RouterModule `forRoot` options to be in line with v10 updates. + */ +export class Rule extends Rules.TypedRule { + applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] { + const ruleName = this.ruleName; + const typeChecker = program.getTypeChecker(); + const sourceFiles = program.getSourceFiles().filter( + s => !s.isDeclarationFile && !program.isSourceFileFromExternalLibrary(s)); + const initialNavigationCollector = new InitialNavigationCollector(typeChecker); + const failures: RuleFailure[] = []; + + // Analyze source files by detecting all ExtraOptions#InitialNavigation assignments + sourceFiles.forEach(sourceFile => initialNavigationCollector.visitNode(sourceFile)); + + const {assignments} = initialNavigationCollector; + const transformer = new InitialNavigationTransform(typeChecker, getUpdateRecorder); + const updateRecorders = new Map(); + + transformer.migrateInitialNavigationAssignments(Array.from(assignments)); + + if (updateRecorders.has(sourceFile)) { + failures.push(...updateRecorders.get(sourceFile)!.failures); + } + + return failures; + + /** Gets the update recorder for the specified source file. */ + function getUpdateRecorder(sourceFile: ts.SourceFile): TslintUpdateRecorder { + if (updateRecorders.has(sourceFile)) { + return updateRecorders.get(sourceFile)!; + } + const recorder = new TslintUpdateRecorder(ruleName, sourceFile); + updateRecorders.set(sourceFile, recorder); + return recorder; + } + } +} diff --git a/packages/core/schematics/migrations/initial-navigation/BUILD.bazel b/packages/core/schematics/migrations/initial-navigation/BUILD.bazel new file mode 100644 index 0000000000..43326967b0 --- /dev/null +++ b/packages/core/schematics/migrations/initial-navigation/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "initial-navigation", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/migrations/google3:__pkg__", + "//packages/core/schematics/migrations/initial-navigation/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/initial-navigation/README.md b/packages/core/schematics/migrations/initial-navigation/README.md new file mode 100644 index 0000000000..a279f273c7 --- /dev/null +++ b/packages/core/schematics/migrations/initial-navigation/README.md @@ -0,0 +1,43 @@ +## initialNavigation migration + +Automatically migrates the `initialNavigation` property of the `RouterModule` to the newly +available options: `enabledNonBlocking` (default), `enabledBlocking`, and `disabled`. + +#### Before +```ts +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +@NgModule({ + imports: [ + RouterModule.forRoot(ROUTES, {initialNavigation: 'legacy_disabled'}), + ] +}) +export class AppModule { +} +``` + +#### After +```ts +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +@NgModule({ + imports: [ + RouterModule.forRoot(ROUTES, {initialNavigation: 'disabled'}), + ] +}) +export class AppModule { +} +``` + +### Disclaimer + +The migration only covers the most common patterns where developers set the `ExtraOptions#InitialNavigation` +option to an outdated value. Therefore, if a user declares the option using a number of other methods, +e.g. shorthand notation, variable declaration, or some other crafty method, they will have to migrate +those options by hand. Otherwise, the compiler will error if the types are sufficiently enforced. + +The basic migration strategy is as follows: +* `legacy_disabled` || `false` => `disabled` +* `legacy_enabled` || `true` => `enabledNonBlocking` (new default) \ No newline at end of file diff --git a/packages/core/schematics/migrations/initial-navigation/collector.ts b/packages/core/schematics/migrations/initial-navigation/collector.ts new file mode 100644 index 0000000000..0c29ee7b3a --- /dev/null +++ b/packages/core/schematics/migrations/initial-navigation/collector.ts @@ -0,0 +1,116 @@ +/** + * @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 {isExtraOptions, isRouterModuleForRoot} from './util'; + + +/** The property name for the options that need to be migrated */ +const INITIAL_NAVIGATION = 'initialNavigation'; + +/** + * Visitor that walks through specified TypeScript nodes and collects all + * found ExtraOptions#InitialNavigation assignments. + */ +export class InitialNavigationCollector { + public assignments: Set = new Set(); + + constructor(private readonly typeChecker: ts.TypeChecker) {} + + visitNode(node: ts.Node) { + let extraOptionsLiteral: ts.ObjectLiteralExpression|null = null; + if (isRouterModuleForRoot(this.typeChecker, node) && node.arguments.length > 0) { + if (node.arguments.length === 1) { + return; + } + + if (ts.isObjectLiteralExpression(node.arguments[1])) { + extraOptionsLiteral = node.arguments[1] as ts.ObjectLiteralExpression; + } else if (ts.isIdentifier(node.arguments[1])) { + extraOptionsLiteral = + this.getLiteralNeedingMigrationFromIdentifier(node.arguments[1] as ts.Identifier); + } + } else if (ts.isVariableDeclaration(node)) { + extraOptionsLiteral = this.getLiteralNeedingMigration(node); + } + + if (extraOptionsLiteral !== null) { + this.visitExtraOptionsLiteral(extraOptionsLiteral); + } else { + // no match found, continue iteration + ts.forEachChild(node, n => this.visitNode(n)); + } + } + + visitExtraOptionsLiteral(extraOptionsLiteral: ts.ObjectLiteralExpression) { + for (const prop of extraOptionsLiteral.properties) { + if (ts.isPropertyAssignment(prop) && + (ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name))) { + if (prop.name.text === INITIAL_NAVIGATION && isValidInitialNavigationValue(prop)) { + this.assignments.add(prop); + } + } else if (ts.isSpreadAssignment(prop) && ts.isIdentifier(prop.expression)) { + const literalFromSpreadAssignment = + this.getLiteralNeedingMigrationFromIdentifier(prop.expression); + if (literalFromSpreadAssignment !== null) { + this.visitExtraOptionsLiteral(literalFromSpreadAssignment); + } + } + } + } + + private getLiteralNeedingMigrationFromIdentifier(id: ts.Identifier): ts.ObjectLiteralExpression + |null { + const symbolForIdentifier = this.typeChecker.getSymbolAtLocation(id); + if (symbolForIdentifier === undefined) { + return null; + } + + if (symbolForIdentifier.declarations.length === 0) { + return null; + } + + const declarationNode = symbolForIdentifier.declarations[0]; + if (!ts.isVariableDeclaration(declarationNode) || declarationNode.initializer === undefined || + !ts.isObjectLiteralExpression(declarationNode.initializer)) { + return null; + } + + return declarationNode.initializer; + } + + private getLiteralNeedingMigration(node: ts.VariableDeclaration): ts.ObjectLiteralExpression + |null { + if (node.initializer === undefined) { + return null; + } + + // declaration could be `x: ExtraOptions = {}` or `x = {} as ExtraOptions` + if (ts.isAsExpression(node.initializer) && + ts.isObjectLiteralExpression(node.initializer.expression) && + isExtraOptions(this.typeChecker, node.initializer.type)) { + return node.initializer.expression; + } else if ( + node.type !== undefined && ts.isObjectLiteralExpression(node.initializer) && + isExtraOptions(this.typeChecker, node.type)) { + return node.initializer; + } + + return null; + } +} + +/** + * Check whether the value assigned to an `initialNavigation` assignment + * conforms to the expected types for ExtraOptions#InitialNavigation + * @param node the property assignment to check + */ +function isValidInitialNavigationValue(node: ts.PropertyAssignment): boolean { + return ts.isStringLiteralLike(node.initializer) || + node.initializer.kind === ts.SyntaxKind.FalseKeyword || + node.initializer.kind === ts.SyntaxKind.TrueKeyword; +} diff --git a/packages/core/schematics/migrations/initial-navigation/google3/BUILD.bazel b/packages/core/schematics/migrations/initial-navigation/google3/BUILD.bazel new file mode 100644 index 0000000000..28baae084a --- /dev/null +++ b/packages/core/schematics/migrations/initial-navigation/google3/BUILD.bazel @@ -0,0 +1,13 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "google3", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + visibility = ["//packages/core/schematics/migrations/google3:__pkg__"], + deps = [ + "//packages/core/schematics/migrations/initial-navigation", + "@npm//tslint", + "@npm//typescript", + ], +) diff --git a/packages/core/schematics/migrations/initial-navigation/google3/tslint_update_recorder.ts b/packages/core/schematics/migrations/initial-navigation/google3/tslint_update_recorder.ts new file mode 100644 index 0000000000..09497f6289 --- /dev/null +++ b/packages/core/schematics/migrations/initial-navigation/google3/tslint_update_recorder.ts @@ -0,0 +1,26 @@ +/** + * @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} from 'tslint'; +import * as ts from 'typescript'; + +import {UpdateRecorder} from '../update_recorder'; + +export class TslintUpdateRecorder implements UpdateRecorder { + failures: RuleFailure[] = []; + + constructor(private ruleName: string, private sourceFile: ts.SourceFile) {} + + updateNode(node: ts.Node, newText: string): void { + this.failures.push(new RuleFailure( + this.sourceFile, node.getStart(), node.getEnd(), `Node needs to be updated to: ${newText}`, + this.ruleName, Replacement.replaceFromTo(node.getStart(), node.getEnd(), newText))); + } + + commitUpdate() {} +} diff --git a/packages/core/schematics/migrations/initial-navigation/index.ts b/packages/core/schematics/migrations/initial-navigation/index.ts new file mode 100644 index 0000000000..1cab6ed87d --- /dev/null +++ b/packages/core/schematics/migrations/initial-navigation/index.ts @@ -0,0 +1,73 @@ +/** + * @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 {InitialNavigationCollector} from './collector'; +import {InitialNavigationTransform} from './transform'; +import {UpdateRecorder} from './update_recorder'; + +/** Entry point for the v10 "initialNavigation RouterModule options" schematic. */ +export default function(): Rule { + return (tree: Tree) => { + const {buildPaths, testPaths} = getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + + if (!buildPaths.length && !testPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot update the "initialNavigation" option for RouterModule'); + } + + for (const tsconfigPath of [...buildPaths, ...testPaths]) { + runInitialNavigationMigration(tree, tsconfigPath, basePath); + } + }; +} + +function runInitialNavigationMigration(tree: Tree, tsconfigPath: string, basePath: string) { + const {program} = createMigrationProgram(tree, tsconfigPath, basePath); + const typeChecker = program.getTypeChecker(); + const initialNavigationCollector = new InitialNavigationCollector(typeChecker); + const sourceFiles = program.getSourceFiles().filter( + f => !f.isDeclarationFile && !program.isSourceFileFromExternalLibrary(f)); + + // Analyze source files by detecting all modules. + sourceFiles.forEach(sourceFile => initialNavigationCollector.visitNode(sourceFile)); + + const {assignments} = initialNavigationCollector; + const transformer = new InitialNavigationTransform(typeChecker, getUpdateRecorder); + const updateRecorders = new Map(); + transformer.migrateInitialNavigationAssignments(Array.from(assignments)); + + // Walk through each update recorder and commit the update. We need to commit the + // updates in batches per source file as there can be only one recorder per source + // file in order to avoid shift character offsets. + updateRecorders.forEach(recorder => recorder.commitUpdate()); + + /** Gets the update recorder for the specified source file. */ + function getUpdateRecorder(sourceFile: ts.SourceFile): UpdateRecorder { + if (updateRecorders.has(sourceFile)) { + return updateRecorders.get(sourceFile)!; + } + const treeRecorder = tree.beginUpdate(relative(basePath, sourceFile.fileName)); + const recorder: UpdateRecorder = { + updateNode(node: ts.Node, newText: string) { + treeRecorder.remove(node.getStart(), node.getWidth()); + treeRecorder.insertRight(node.getStart(), newText); + }, + commitUpdate() { + tree.commitUpdate(treeRecorder); + } + }; + updateRecorders.set(sourceFile, recorder); + return recorder; + } +} diff --git a/packages/core/schematics/migrations/initial-navigation/transform.ts b/packages/core/schematics/migrations/initial-navigation/transform.ts new file mode 100644 index 0000000000..32b105605d --- /dev/null +++ b/packages/core/schematics/migrations/initial-navigation/transform.ts @@ -0,0 +1,75 @@ +/** + * @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 {UpdateRecorder} from './update_recorder'; + + +export class InitialNavigationTransform { + private printer = ts.createPrinter(); + + constructor( + private typeChecker: ts.TypeChecker, + private getUpdateRecorder: (sf: ts.SourceFile) => UpdateRecorder) {} + + /** Migrate the ExtraOptions#InitialNavigation property assignments. */ + migrateInitialNavigationAssignments(literals: ts.PropertyAssignment[]) { + literals.forEach(l => this.migrateAssignment(l)); + } + + /** Migrate an ExtraOptions#InitialNavigation expression to use the new options format. */ + migrateAssignment(assignment: ts.PropertyAssignment) { + const newInitializer = getUpdatedInitialNavigationValue(assignment.initializer); + if (newInitializer) { + const newAssignment = + ts.updatePropertyAssignment(assignment, assignment.name, newInitializer); + this._updateNode(assignment, newAssignment); + } + } + + private _updateNode(node: ts.Node, newNode: ts.Node) { + const newText = this.printer.printNode(ts.EmitHint.Unspecified, newNode, node.getSourceFile()); + const recorder = this.getUpdateRecorder(node.getSourceFile()); + recorder.updateNode(node, newText); + } +} + +/** + * Updates the deprecated initialNavigation options to their v10 equivalents + * (or as close as we can get). + * @param initializer the old initializer to update + */ +function getUpdatedInitialNavigationValue(initializer: ts.Expression): ts.Expression|null { + const oldText: string|boolean = ts.isStringLiteralLike(initializer) ? + initializer.text : + initializer.kind === ts.SyntaxKind.TrueKeyword; + let newText: string|undefined; + switch (oldText) { + case false: + case 'legacy_disabled': + newText = 'disabled'; + break; + case true: + case 'legacy_enabled': + newText = 'enabledNonBlocking'; + break; + } + + return !!newText ? ts.createIdentifier(`'${newText}'`) : null; +} + +/** + * Check whether the value assigned to an `initialNavigation` assignment + * conforms to the expected types for ExtraOptions#InitialNavigation + * @param node the property assignment to check + */ +function isValidInitialNavigationValue(node: ts.PropertyAssignment): boolean { + return ts.isStringLiteralLike(node.initializer) || + node.initializer.kind === ts.SyntaxKind.FalseKeyword || + node.initializer.kind === ts.SyntaxKind.TrueKeyword; +} diff --git a/packages/core/schematics/migrations/initial-navigation/update_recorder.ts b/packages/core/schematics/migrations/initial-navigation/update_recorder.ts new file mode 100644 index 0000000000..82e1787968 --- /dev/null +++ b/packages/core/schematics/migrations/initial-navigation/update_recorder.ts @@ -0,0 +1,19 @@ +/** + * @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'; + +/** + * Update recorder interface that is used to transform source files in a non-colliding + * way. Also this indirection makes it possible to re-use logic for both TSLint rules + * and CLI devkit schematic updates. + */ +export interface UpdateRecorder { + updateNode(node: ts.Node, newText: string): void; + commitUpdate(): void; +} diff --git a/packages/core/schematics/migrations/initial-navigation/util.ts b/packages/core/schematics/migrations/initial-navigation/util.ts new file mode 100644 index 0000000000..d8a7c56562 --- /dev/null +++ b/packages/core/schematics/migrations/initial-navigation/util.ts @@ -0,0 +1,33 @@ +/** + * @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 {getImportOfIdentifier} from '../../utils/typescript/imports'; + +/** Determine whether a node is a ModuleWithProviders type reference node without a generic type */ +export function isRouterModuleForRoot( + typeChecker: ts.TypeChecker, node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || + !ts.isIdentifier(node.expression.expression) || node.expression.name.text !== 'forRoot') { + return false; + } + const imp = getImportOfIdentifier(typeChecker, node.expression.expression); + return !!imp && imp.name === 'RouterModule' && imp.importModule === '@angular/router' && + !node.typeArguments; +} + +export function isExtraOptions( + typeChecker: ts.TypeChecker, node: ts.Node): node is ts.TypeReferenceNode { + if (!ts.isTypeReferenceNode(node) || !ts.isIdentifier(node.typeName)) { + return false; + } + + const imp = getImportOfIdentifier(typeChecker, node.typeName); + return imp !== null && imp.name === 'ExtraOptions' && imp.importModule === '@angular/router' && + !node.typeArguments; +} diff --git a/packages/core/schematics/migrations/module-with-providers/transform.ts b/packages/core/schematics/migrations/module-with-providers/transform.ts index 3c5eb7e0f5..2995f83a7e 100644 --- a/packages/core/schematics/migrations/module-with-providers/transform.ts +++ b/packages/core/schematics/migrations/module-with-providers/transform.ts @@ -20,7 +20,7 @@ export interface AnalysisFailure { message: string; } -const TODO_COMMENT = 'TODO: The following node requires a generic type for `ModuleWithProviders'; +const TODO_COMMENT = 'TODO: The following node requires a generic type for `ModuleWithProviders`'; export class ModuleWithProvidersTransform { private printer = ts.createPrinter(); diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index 6825f20bda..a26b22892f 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -10,6 +10,7 @@ ts_library( deps = [ "//packages/core/schematics/migrations/abstract-control-parent", "//packages/core/schematics/migrations/dynamic-queries", + "//packages/core/schematics/migrations/initial-navigation", "//packages/core/schematics/migrations/missing-injectable", "//packages/core/schematics/migrations/module-with-providers", "//packages/core/schematics/migrations/move-document", diff --git a/packages/core/schematics/test/google3/initial_navigation_rule_spec.ts b/packages/core/schematics/test/google3/initial_navigation_rule_spec.ts new file mode 100644 index 0000000000..07cddb8fdb --- /dev/null +++ b/packages/core/schematics/test/google3/initial_navigation_rule_spec.ts @@ -0,0 +1,211 @@ +/** + * @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 initial navigation tslint rule', () => { + const rulesDirectory = dirname(require.resolve('../../migrations/google3/initialNavigationRule')); + + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(process.env['TEST_TMPDIR']!, 'google3-test'); + shx.mkdir('-p', tmpDir); + + writeFile('tsconfig.json', JSON.stringify({compilerOptions: {module: 'es2015'}})); + }); + + afterEach(() => shx.rm('-r', tmpDir)); + + function runTSLint(fix = true) { + const program = Linter.createProgram(join(tmpDir, 'tsconfig.json')); + const linter = new Linter({fix, rulesDirectory: [rulesDirectory]}, program); + const config = Configuration.parseConfigFile({rules: {'initial-navigation': 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 migrate legacy_disabled to disabled', () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: 'legacy_disabled'}), + ] + }) + export class AppModule { + } + `); + + + runTSLint(); + + expect(getFile('/index.ts')).toContain(`{initialNavigation: 'disabled'}`); + }); + + it(`should migrate false to disabled`, () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: false}), + ] + }) + export class AppModule { + } + `); + + runTSLint(); + + expect(getFile('/index.ts')).toContain(`{initialNavigation: 'disabled'}`); + }); + + it('should migrate legacy_enabled to enabledNonBlocking', () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: 'legacy_enabled'}), + ] + }) + export class AppModule { + } + `); + + runTSLint(true); + + expect(getFile('/index.ts')).toContain(`{initialNavigation: 'enabledNonBlocking'}`); + }); + + it(`should migrate true to enabledNonBlocking`, () => { + writeFile('/index.ts', ` + mport { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: true}), + ] + }) + export class AppModule { + } + `); + + runTSLint(true); + + expect(getFile('/index.ts')).toContain(`{initialNavigation: 'enabledNonBlocking'}`); + }); + + it('should migrate nested objects', () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + const options = {initialNavigation: 'legacy_enabled'}; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: 'legacy_disabled', ...options}), + ] + }) + export class AppModule { + } + `); + + runTSLint(true); + expect(getFile('/index.ts')) + .toContain(`const options = {initialNavigation: 'enabledNonBlocking'};`); + expect(getFile('/index.ts')).toContain(`{initialNavigation: 'disabled', ...options}`); + }); + + it('should migrate nested objects mixed validity', () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + const options = {initialNavigation: 'legacy_enabled'}; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: 'disabled', ...options}), + ] + }) + export class AppModule { + } + `); + + runTSLint(true); + expect(getFile('/index.ts')) + .toContain(`const options = {initialNavigation: 'enabledNonBlocking'};`); + }); + + it('should migrate nested objects opposite order', () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + const options = {initialNavigation: 'legacy_enabled'}; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {...options, initialNavigation: 'legacy_disabled'}), + ] + }) + export class AppModule { + } + `); + + runTSLint(true); + expect(getFile('/index.ts')) + .toContain(`const options = {initialNavigation: 'enabledNonBlocking'};`); + expect(getFile('/index.ts')).toContain(`{...options, initialNavigation: 'disabled'}`); + }); + + it('should migrate nested objects mixed validity opposite order', () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + const options = {initialNavigation: 'legacy_enabled'}; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {...options, initialNavigation: 'disabled'}), + ] + }) + export class AppModule { + } + `); + + runTSLint(true); + expect(getFile('/index.ts')) + .toContain(`const options = {initialNavigation: 'enabledNonBlocking'};`); + expect(getFile('/index.ts')).toContain(`{...options, initialNavigation: 'disabled'}`); + }); +}); diff --git a/packages/core/schematics/test/initial_navigation_migration_spec.ts b/packages/core/schematics/test/initial_navigation_migration_spec.ts new file mode 100644 index 0000000000..22d9c0c26f --- /dev/null +++ b/packages/core/schematics/test/initial_navigation_migration_spec.ts @@ -0,0 +1,239 @@ +/** + * @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('initial navigation 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 migrate legacy_disabled to disabled', async () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: 'legacy_disabled'}), + ] + }) + export class AppModule { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`{initialNavigation: 'disabled'}`); + }); + + it('should migrate false to disabled', async () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: false}), + ] + }) + export class AppModule { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`{initialNavigation: 'disabled'}`); + }); + + it('should migrate legacy_enabled to enabledNonBlocking', async () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: 'legacy_enabled'}), + ] + }) + export class AppModule { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`{initialNavigation: 'enabledNonBlocking'}`); + }); + + it('should migrate true to enabledNonBlocking', async () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: true}), + ] + }) + export class AppModule { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')).toContain(`{initialNavigation: 'enabledNonBlocking'}`); + }); + + it('should migrate nested objects', async () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + const options = {initialNavigation: 'legacy_enabled'}; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: 'legacy_disabled', ...options}), + ] + }) + export class AppModule { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`const options = {initialNavigation: 'enabledNonBlocking'};`); + expect(tree.readContent('/index.ts')).toContain(`{initialNavigation: 'disabled', ...options}`); + }); + + it('should migrate nested objects mixed validity', async () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + const options = {initialNavigation: 'legacy_enabled'}; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {initialNavigation: 'disabled', ...options}), + ] + }) + export class AppModule { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`const options = {initialNavigation: 'enabledNonBlocking'};`); + }); + + + it('should migrate nested objects opposite order', async () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + const options = {initialNavigation: 'legacy_enabled'}; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {...options, initialNavigation: 'legacy_disabled'}), + ] + }) + export class AppModule { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`const options = {initialNavigation: 'enabledNonBlocking'};`); + expect(tree.readContent('/index.ts')).toContain(`{...options, initialNavigation: 'disabled'}`); + }); + + it('should migrate nested objects mixed validity opposite order', async () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + const options = {initialNavigation: 'legacy_enabled'}; + + @NgModule({ + imports: [ + RouterModule.forRoot([], {...options, initialNavigation: 'disabled'}), + ] + }) + export class AppModule { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`const options = {initialNavigation: 'enabledNonBlocking'};`); + expect(tree.readContent('/index.ts')).toContain(`disabled`); + }); + + it('should not migrate variable not used in forRoot', async () => { + writeFile('/index.ts', ` + import { NgModule } from '@angular/core'; + import { RouterModule } from '@angular/router'; + + const options = {initialNavigation: 'legacy_enabled'}; + + @NgModule({ + imports: [ + RouterModule.forRoot([]), + ] + }) + export class AppModule { + } + `); + + await runMigration(); + expect(tree.readContent('/index.ts')) + .toContain(`const options = {initialNavigation: 'legacy_enabled'};`); + expect(tree.readContent('/index.ts')).toContain(`RouterModule.forRoot([])`); + }); + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + return runner.runSchematicAsync('migration-v11-router-initial-navigation-options', {}, tree) + .toPromise(); + } +});