From a154db8a81cbdfed8c3d0db1e2a5bf43aa3e0bbf Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 20 Jan 2023 17:19:18 +0100 Subject: [PATCH] feat(core): add ng generate schematic to convert declarations to standalone (#48790) Implements a new `ng generate @angular/core:standalone` schematic that allows the user to convert all the declarations in a set of NgModules to standalone. PR Close #48790 --- packages/core/schematics/BUILD.bazel | 2 + packages/core/schematics/collection.json | 9 +- .../standalone-migration/BUILD.bazel | 40 + .../standalone-migration/README.md | 2 + .../ng-generate/standalone-migration/index.ts | 98 ++ .../standalone-migration/schema.json | 33 + .../standalone-migration/to-standalone.ts | 500 +++++++ .../ng-generate/standalone-migration/util.ts | 256 ++++ packages/core/schematics/test/BUILD.bazel | 3 + .../test/standalone_migration_spec.ts | 1175 +++++++++++++++++ 10 files changed, 2117 insertions(+), 1 deletion(-) create mode 100644 packages/core/schematics/ng-generate/standalone-migration/BUILD.bazel create mode 100644 packages/core/schematics/ng-generate/standalone-migration/README.md create mode 100644 packages/core/schematics/ng-generate/standalone-migration/index.ts create mode 100644 packages/core/schematics/ng-generate/standalone-migration/schema.json create mode 100644 packages/core/schematics/ng-generate/standalone-migration/to-standalone.ts create mode 100644 packages/core/schematics/ng-generate/standalone-migration/util.ts create mode 100644 packages/core/schematics/test/standalone_migration_spec.ts diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index 05169ae92be..f6d226f34b1 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -12,11 +12,13 @@ pkg_npm( "collection.json", "migrations.json", "package.json", + "//packages/core/schematics/ng-generate/standalone-migration:static_files", ], validate = False, visibility = ["//packages/core:__pkg__"], deps = [ "//packages/core/schematics/migrations/relative-link-resolution:bundle", "//packages/core/schematics/migrations/router-link-with-href:bundle", + "//packages/core/schematics/ng-generate/standalone-migration:bundle", ], ) diff --git a/packages/core/schematics/collection.json b/packages/core/schematics/collection.json index 63001b44588..c5c9284b2ba 100644 --- a/packages/core/schematics/collection.json +++ b/packages/core/schematics/collection.json @@ -1,3 +1,10 @@ { - "schematics": {} + "schematics": { + "standalone-migration": { + "description": "Converts the entire application or a part of it to standalone", + "factory": "./ng-generate/standalone-migration/bundle", + "schema": "./ng-generate/standalone-migration/schema.json", + "aliases": ["standalone"] + } + } } diff --git a/packages/core/schematics/ng-generate/standalone-migration/BUILD.bazel b/packages/core/schematics/ng-generate/standalone-migration/BUILD.bazel new file mode 100644 index 00000000000..d4292a92171 --- /dev/null +++ b/packages/core/schematics/ng-generate/standalone-migration/BUILD.bazel @@ -0,0 +1,40 @@ +load("//tools:defaults.bzl", "esbuild", "ts_library") + +package( + default_visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/migrations/google3:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], +) + +filegroup( + name = "static_files", + srcs = ["schema.json"], +) + +ts_library( + name = "standalone-migration", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + deps = [ + "//packages/compiler-cli", + "//packages/compiler-cli/private", + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) + +esbuild( + name = "bundle", + entry_point = ":index.ts", + external = [ + "@angular-devkit/*", + "typescript", + ], + format = "cjs", + platform = "node", + deps = [":standalone-migration"], +) diff --git a/packages/core/schematics/ng-generate/standalone-migration/README.md b/packages/core/schematics/ng-generate/standalone-migration/README.md new file mode 100644 index 00000000000..773669b29e5 --- /dev/null +++ b/packages/core/schematics/ng-generate/standalone-migration/README.md @@ -0,0 +1,2 @@ +# Standalone migration +TODO diff --git a/packages/core/schematics/ng-generate/standalone-migration/index.ts b/packages/core/schematics/ng-generate/standalone-migration/index.ts new file mode 100644 index 00000000000..4b99dfe720c --- /dev/null +++ b/packages/core/schematics/ng-generate/standalone-migration/index.ts @@ -0,0 +1,98 @@ +/** + * @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 {createProgram, NgtscProgram} from '@angular/compiler-cli'; +import {existsSync, statSync} from 'fs'; +import {join, relative} from 'path'; +import ts from 'typescript'; + +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; +import {canMigrateFile, createProgramOptions} from '../../utils/typescript/compiler_host'; + +import {toStandalone} from './to-standalone'; +import {ChangesByFile} from './util'; + +enum MigrationMode { + toStandalone = 'convert-to-standalone', +} + +interface Options { + path: string; + mode: MigrationMode; +} + +export default function(options: Options): Rule { + return async (tree) => { + const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + const allPaths = [...buildPaths, ...testPaths]; + + if (!allPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot run the standalone migration.'); + } + + for (const tsconfigPath of allPaths) { + standaloneMigration(tree, tsconfigPath, basePath, options); + } + }; +} + +function standaloneMigration(tree: Tree, tsconfigPath: string, basePath: string, options: Options) { + if (options.path.startsWith('..')) { + throw new SchematicsException( + 'Cannot run standalone migration outside of the current project.'); + } + + const {host, rootNames} = createProgramOptions(tree, tsconfigPath, basePath); + const program = createProgram({ + rootNames, + host, + options: {_enableTemplateTypeChecker: true, compileNonExportedClasses: true} + }) as NgtscProgram; + const printer = ts.createPrinter(); + const pathToMigrate = join(basePath, options.path); + + if (existsSync(pathToMigrate) && !statSync(pathToMigrate).isDirectory()) { + throw new SchematicsException(`Migration path ${ + pathToMigrate} has to be a directory. Cannot run the standalone migration.`); + } + + const sourceFiles = program.getTsProgram().getSourceFiles().filter(sourceFile => { + return sourceFile.fileName.startsWith(pathToMigrate) && + canMigrateFile(basePath, sourceFile, program.getTsProgram()); + }); + + if (sourceFiles.length === 0) { + throw new SchematicsException(`Could not find any files to migrate under the path ${ + pathToMigrate}. Cannot run the standalone migration.`); + } + + let pendingChanges: ChangesByFile; + + if (options.mode === MigrationMode.toStandalone) { + pendingChanges = toStandalone(sourceFiles, program, printer); + } else { + throw new SchematicsException( + `Unknown schematic mode ${options.mode}. Cannot run the standalone migration.`); + } + + for (const [file, changes] of pendingChanges.entries()) { + const update = tree.beginUpdate(relative(basePath, file.fileName)); + + changes.forEach(change => { + if (change.removeLength != null) { + update.remove(change.start, change.removeLength); + } + update.insertRight(change.start, change.text); + }); + + tree.commitUpdate(update); + } +} diff --git a/packages/core/schematics/ng-generate/standalone-migration/schema.json b/packages/core/schematics/ng-generate/standalone-migration/schema.json new file mode 100644 index 00000000000..f127fbba014 --- /dev/null +++ b/packages/core/schematics/ng-generate/standalone-migration/schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "AngularStandaloneMigration", + "title": "Angular Standalone Migration Schema", + "type": "object", + "properties": { + "mode": { + "description": "Operation that should be performed by the migrator", + "type": "string", + "default": "convert-to-standalone", + "x-prompt": { + "message": "Choose the type of migration:", + "type": "list", + "items": [ + { + "value": "convert-to-standalone", + "label": "Convert all components, directives and pipes to standalone" + } + ] + } + }, + "path": { + "type": "string", + "$default": { + "$source": "workingDirectory" + }, + "description": "Path relative to the project root in which to look for declarations to migrate to standalone", + "x-prompt": "Which path in your project should be migrated to standalone?", + "default": "./" + } + }, + "required": ["mode", "path"] +} diff --git a/packages/core/schematics/ng-generate/standalone-migration/to-standalone.ts b/packages/core/schematics/ng-generate/standalone-migration/to-standalone.ts new file mode 100644 index 00000000000..d3f8694fcbb --- /dev/null +++ b/packages/core/schematics/ng-generate/standalone-migration/to-standalone.ts @@ -0,0 +1,500 @@ +/*! + * @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 {NgtscProgram} from '@angular/compiler-cli'; +import {PotentialImport, PotentialImportKind, PotentialImportMode, Reference, TemplateTypeChecker} from '@angular/compiler-cli/private/migrations'; +import ts from 'typescript'; + +import {getAngularDecorators, NgDecorator} from '../../utils/ng_decorators'; +import {getImportSpecifier} from '../../utils/typescript/imports'; +import {isReferenceToImport} from '../../utils/typescript/symbol'; + +import {ChangesByFile, ChangeTracker, findClassDeclaration, findLiteralProperty, NamedClassDeclaration} from './util'; + +/** + * Converts all declarations in the specified files to standalone. + * @param sourceFiles Files that should be migrated. + * @param program + * @param printer + */ +export function toStandalone( + sourceFiles: ts.SourceFile[], program: NgtscProgram, printer: ts.Printer): ChangesByFile { + const templateTypeChecker = program.compiler.getTemplateTypeChecker(); + const typeChecker = program.getTsProgram().getTypeChecker(); + const modulesToMigrate: ts.ClassDeclaration[] = []; + const testObjectsToMigrate: ts.ObjectLiteralExpression[] = []; + const declarations: Reference[] = []; + const tracker = new ChangeTracker(printer); + + for (const sourceFile of sourceFiles) { + const {modules, testObjects} = findModulesToMigrate(sourceFile, typeChecker); + modules.forEach( + module => declarations.push(...extractDeclarationsFromModule(module, templateTypeChecker))); + modulesToMigrate.push(...modules); + testObjectsToMigrate.push(...testObjects); + } + + for (const declaration of declarations) { + convertNgModuleDeclarationToStandalone(declaration, declarations, tracker, templateTypeChecker); + } + + for (const node of modulesToMigrate) { + migrateNgModuleClass(node, tracker, templateTypeChecker); + } + + migrateTestDeclarations(testObjectsToMigrate, tracker, typeChecker); + return tracker.recordChanges(); +} + +/** + * Converts a single declaration defined through an NgModule to standalone. + * @param ref References to the declaration being converted. + * @param tracker Tracker used to track the file changes. + * @param allDeclarations All the declarations that are being converted as a part of this migration. + * @param typeChecker + */ +export function convertNgModuleDeclarationToStandalone( + ref: Reference, allDeclarations: Reference[], + tracker: ChangeTracker, typeChecker: TemplateTypeChecker): void { + const directiveMeta = typeChecker.getDirectiveMetadata(ref.node); + + if (directiveMeta && directiveMeta.decorator && !directiveMeta.isStandalone) { + let decorator = addStandaloneToDecorator(directiveMeta.decorator); + + if (directiveMeta.isComponent) { + const importsToAdd = + getComponentImportExpressions(ref, allDeclarations, tracker, typeChecker); + + if (importsToAdd.length > 0) { + decorator = addPropertyToAngularDecorator( + decorator, + ts.factory.createPropertyAssignment( + 'imports', ts.factory.createArrayLiteralExpression(importsToAdd))); + } + } + + tracker.replaceNode(directiveMeta.decorator, decorator); + } else { + const pipeMeta = typeChecker.getPipeMetadata(ref.node); + + if (pipeMeta && pipeMeta.decorator && !pipeMeta.isStandalone) { + tracker.replaceNode(pipeMeta.decorator, addStandaloneToDecorator(pipeMeta.decorator)); + } + } +} + +/** + * Gets the expressions that should be added to a component's + * `imports` array based on its template dependencies. + * @param ref Reference to the component class. + * @param allDeclarations All the declarations that are being converted as a part of this migration. + * @param tracker + * @param typeChecker + */ +function getComponentImportExpressions( + ref: Reference, allDeclarations: Reference[], + tracker: ChangeTracker, typeChecker: TemplateTypeChecker): ts.Expression[] { + const templateDependencies = findTemplateDependencies(ref, typeChecker); + const usedDependenciesInMigration = new Set(templateDependencies.filter( + dep => allDeclarations.find(current => current.node === dep.node))); + const imports: ts.Expression[] = []; + const seenImports = new Set(); + + for (const dep of templateDependencies) { + const importLocation = findImportLocation( + dep as Reference, ref, + usedDependenciesInMigration.has(dep) ? PotentialImportMode.ForceDirect : + PotentialImportMode.Normal, + typeChecker); + + if (importLocation && !seenImports.has(importLocation.symbolName)) { + if (importLocation.moduleSpecifier) { + const identifier = tracker.addImport( + ref.node.getSourceFile(), importLocation.symbolName, importLocation.moduleSpecifier); + imports.push(identifier); + } else { + imports.push(ts.factory.createIdentifier(importLocation.symbolName)); + } + + seenImports.add(importLocation.symbolName); + } + } + + return imports; +} + +/** + * Moves all of the declarations of a class decorated with `@NgModule` to its imports. + * @param node Class being migrated. + * @param tracker + * @param typeChecker + */ +function migrateNgModuleClass( + node: ts.ClassDeclaration, tracker: ChangeTracker, typeChecker: TemplateTypeChecker) { + const decorator = typeChecker.getNgModuleMetadata(node)?.decorator; + + if (decorator && ts.isCallExpression(decorator.expression) && + decorator.expression.arguments.length === 1 && + ts.isObjectLiteralExpression(decorator.expression.arguments[0])) { + moveDeclarationsToImports(decorator.expression.arguments[0], tracker); + } +} + +/** + * Moves all the symbol references from the `declarations` array to the `imports` + * array of an `NgModule` class and removes the `declarations`. + * @param literal Object literal used to configure the module that should be migrated. + * @param tracker + */ +function moveDeclarationsToImports( + literal: ts.ObjectLiteralExpression, tracker: ChangeTracker): void { + const properties = + literal.properties + .map(prop => { + if (!isNamedPropertyAssignment(prop)) { + return prop; + } + + // If there's no `imports`, copy the initializer from the `declarations`. + if (prop.name.text === 'declarations' && !findLiteralProperty(literal, 'imports')) { + return ts.factory.createPropertyAssignment('imports', prop.initializer); + } + + // Migrate the `imports`. + if (prop.name.text === 'imports') { + const declarations = findLiteralProperty(literal, 'declarations'); + return declarations && ts.isPropertyAssignment(declarations) ? + mergeDeclarationsIntoImports(declarations, prop) : + prop; + } + + // Retain any remaining properties. + return prop; + }) + // Drop the `declarations` property. + .filter(prop => isNamedPropertyAssignment(prop) && prop.name.text !== 'declarations'); + + tracker.replaceNode( + literal, ts.factory.createObjectLiteralExpression(properties, true), ts.EmitHint.Expression); +} + +/** + * Merges the `declarations` and `imports` arrays of an NgModule. + * @param declarations Node that declares the `declarations` property. + * @param imports Node that declares the `imports` property. + */ +function mergeDeclarationsIntoImports( + declarations: ts.PropertyAssignment, imports: ts.PropertyAssignment) { + const importsIsArray = ts.isArrayLiteralExpression(imports.initializer); + const declarationsIsArray = ts.isArrayLiteralExpression(declarations.initializer); + let arrayElements: ts.Expression[]; + + if (importsIsArray && declarationsIsArray) { + // Both values are arrays so they can be merged statically. + // E.g. `imports: [Import1, Import2, Declaration1]`. + arrayElements = [...imports.initializer.elements, ...declarations.initializer.elements]; + } else if (importsIsArray) { + // Only the imports is an array so we need to use a spread to merge the + // declarations. E.g. `imports: [Import1, Import2, ...DECLARATIONS]`. + arrayElements = + [...imports.initializer.elements, ts.factory.createSpreadElement(declarations.initializer)]; + } else if (declarationsIsArray) { + // Declarations are an array, but imports aren't so we have to generate a spread. + // E.g. `imports: [...IMPORTS, Declaration1, Declaration2]`. + arrayElements = + [ts.factory.createSpreadElement(imports.initializer), ...declarations.initializer.elements]; + } else { + // Neither the declarations nor the imports are arrays so we have to use spread for + // both. E.g. `imports: [...IMPORTS, ...DECLARATIONS]`. + arrayElements = [ + ts.factory.createSpreadElement(imports.initializer), + ts.factory.createSpreadElement(declarations.initializer) + ]; + } + + return ts.factory.createPropertyAssignment( + imports.name, ts.factory.createArrayLiteralExpression(arrayElements)); +} + +/** Adds `standalone: true` to a decorator node. */ +function addStandaloneToDecorator(node: ts.Decorator): ts.Decorator { + return addPropertyToAngularDecorator( + node, + ts.factory.createPropertyAssignment( + 'standalone', ts.factory.createToken(ts.SyntaxKind.TrueKeyword))); +} + +/** + * Adds a property to an Angular decorator node. + * @param node Decorator to which to add the property. + * @param property Property to add. + */ +function addPropertyToAngularDecorator( + node: ts.Decorator, property: ts.PropertyAssignment): ts.Decorator { + // Invalid decorator. + if (!ts.isCallExpression(node.expression) || node.expression.arguments.length > 1) { + return node; + } + + let literalProperties: ts.ObjectLiteralElementLike[]; + + if (node.expression.arguments.length === 0) { + literalProperties = [property]; + } else if (ts.isObjectLiteralExpression(node.expression.arguments[0])) { + literalProperties = [...node.expression.arguments[0].properties, property]; + } else { + // Unsupported case (e.g. `@Component(SOME_CONST)`). Return the original node. + return node; + } + + return ts.factory.updateDecorator( + node, + ts.factory.createCallExpression(node.expression.expression, node.expression.typeArguments, [ + ts.factory.createObjectLiteralExpression(literalProperties, literalProperties.length > 1) + ])); +} + +/** Checks if a node is a `PropertyAssignment` with a name. */ +function isNamedPropertyAssignment(node: ts.Node): node is ts.PropertyAssignment& + {name: ts.Identifier} { + return ts.isPropertyAssignment(node) && node.name && ts.isIdentifier(node.name); +} + +/** + * Finds the import from which to bring in a template dependency of a component. + * @param target Dependency that we're searching for. + * @param inComponent Component in which the dependency is used. + * @param importMode Mode in which to resolve the import target. + * @param typeChecker + */ +function findImportLocation( + target: Reference, inComponent: Reference, + importMode: PotentialImportMode, typeChecker: TemplateTypeChecker): PotentialImport|null { + const importLocations = typeChecker.getPotentialImportsFor(target, inComponent.node, importMode); + let firstModuleImport: PotentialImport|null = null; + + for (const location of importLocations) { + // Prefer a standalone import, if we can find one. + // Otherwise fall back to the first module-based import. + if (location.kind === PotentialImportKind.Standalone) { + return location; + } + if (location.kind === PotentialImportKind.NgModule && !firstModuleImport) { + firstModuleImport = location; + } + } + + return firstModuleImport; +} + +/** + * Checks whether a node is an `NgModule` metadata element with at least one element. + * E.g. `declarations: [Foo]` or `declarations: SOME_VAR` would match this description, + * but not `declarations: []`. + */ +function hasNgModuleMetadataElements(node: ts.Node): node is ts.PropertyAssignment& + {initializer: ts.ArrayLiteralExpression} { + return ts.isPropertyAssignment(node) && + (!ts.isArrayLiteralExpression(node.initializer) || node.initializer.elements.length > 0); +} + +/** Finds all modules whose declarations can be migrated. */ +function findModulesToMigrate(sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) { + const modules: ts.ClassDeclaration[] = []; + const testObjects: ts.ObjectLiteralExpression[] = []; + const testBedImport = getImportSpecifier(sourceFile, '@angular/core/testing', 'TestBed'); + const catalystImport = getImportSpecifier(sourceFile, /testing\/catalyst$/, 'setupModule'); + + sourceFile.forEachChild(function walk(node) { + if (ts.isClassDeclaration(node)) { + const decorator = getAngularDecorators(typeChecker, ts.getDecorators(node) || []) + .find(current => current.name === 'NgModule'); + const metadata = decorator?.node.expression.arguments[0]; + + if (metadata && ts.isObjectLiteralExpression(metadata)) { + const declarations = findLiteralProperty(metadata, 'declarations'); + const bootstrap = findLiteralProperty(metadata, 'bootstrap'); + const hasDeclarations = declarations != null && hasNgModuleMetadataElements(declarations); + const hasBootstrap = bootstrap != null && hasNgModuleMetadataElements(bootstrap); + + // Skip modules that bootstrap components since changing them would also involve + // converting `bootstrapModule` calls to `bootstrapApplication`. These declarations + // will be converted to standalone in the `standalone-bootstrap` step. + if (hasDeclarations && !hasBootstrap) { + modules.push(node); + } + } + } else if ( + ts.isCallExpression(node) && node.arguments.length > 0 && + ts.isObjectLiteralExpression(node.arguments[0])) { + if ((testBedImport && ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'configureTestingModule' && + isReferenceToImport(typeChecker, node.expression.expression, testBedImport)) || + (catalystImport && ts.isIdentifier(node.expression) && + isReferenceToImport(typeChecker, node.expression, catalystImport))) { + testObjects.push(node.arguments[0]); + } + } + + node.forEachChild(walk); + }); + + return {modules, testObjects}; +} + +/** + * Finds the classes corresponding to dependencies used in a component's template. + * @param ref Component in whose template we're looking for dependencies. + * @param typeChecker + */ +function findTemplateDependencies( + ref: Reference, + typeChecker: TemplateTypeChecker): Reference[] { + const results: Reference[] = []; + const usedDirectives = typeChecker.getUsedDirectives(ref.node); + const usedPipes = typeChecker.getUsedPipes(ref.node); + + if (usedDirectives !== null) { + for (const dir of usedDirectives) { + if (ts.isClassDeclaration(dir.ref.node)) { + results.push(dir.ref as Reference); + } + } + } + + if (usedPipes !== null) { + const potentialPipes = typeChecker.getPotentialPipes(ref.node); + + for (const pipe of potentialPipes) { + if (ts.isClassDeclaration(pipe.ref.node) && + usedPipes.some(current => pipe.name === current)) { + results.push(pipe.ref as Reference); + } + } + } + + return results; +} + +/** Extracts classes that are referred to in a module's `declarations` array. */ +function extractDeclarationsFromModule( + ngModule: ts.ClassDeclaration, + typeChecker: TemplateTypeChecker): Reference[] { + return typeChecker.getNgModuleMetadata(ngModule)?.declarations.filter( + decl => ts.isClassDeclaration(decl.node)) as Reference[] || + []; +} + +/** + * Migrates the `declarations` from a unit test file to standalone. + * @param testObjects Object literals used to configure the testing modules. + * @param tracker + * @param typeChecker + */ +function migrateTestDeclarations( + testObjects: ts.ObjectLiteralExpression[], tracker: ChangeTracker, + typeChecker: ts.TypeChecker) { + const {decorators, componentImports} = analyzeTestingModules(testObjects, tracker, typeChecker); + + for (const decorator of decorators) { + if (decorator.name === 'Pipe' || decorator.name === 'Directive') { + tracker.replaceNode(decorator.node, addStandaloneToDecorator(decorator.node)); + } else if (decorator.name === 'Component') { + const newDecorator = addStandaloneToDecorator(decorator.node); + const importsToAdd = componentImports.get(decorator.node); + + if (importsToAdd && importsToAdd.size > 0) { + tracker.replaceNode( + decorator.node, + addPropertyToAngularDecorator( + newDecorator, + ts.factory.createPropertyAssignment( + 'imports', ts.factory.createArrayLiteralExpression(Array.from(importsToAdd))))); + } else { + tracker.replaceNode(decorator.node, newDecorator); + } + } + } +} + +/** + * Analyzes a set of objects used to configure testing modules and returns the AST + * nodes that need to be migrated and the imports that should be added to the imports + * of any declared components. + * @param testObjects Object literals that should be analyzed. + */ +function analyzeTestingModules( + testObjects: ts.ObjectLiteralExpression[], tracker: ChangeTracker, + typeChecker: ts.TypeChecker) { + const seenDeclarations = new Set(); + const decorators: NgDecorator[] = []; + const componentImports = new Map>(); + + for (const obj of testObjects) { + const declarations = extractDeclarationsFromTestObject(obj, typeChecker); + const importsProp = findLiteralProperty(obj, 'imports'); + const importElements = importsProp && hasNgModuleMetadataElements(importsProp) ? + importsProp.initializer.elements : + null; + + moveDeclarationsToImports(obj, tracker); + + for (const decl of declarations) { + if (seenDeclarations.has(decl)) { + continue; + } + + const [decorator] = getAngularDecorators(typeChecker, ts.getDecorators(decl) || []); + + if (decorator) { + seenDeclarations.add(decl); + decorators.push(decorator); + + if (decorator.name === 'Component' && importElements) { + // We try to de-duplicate the imports being added to a component, because it may be + // declared in different testing modules with a different set of imports. + let imports = componentImports.get(decorator.node); + if (!imports) { + imports = new Set(); + componentImports.set(decorator.node, imports); + } + importElements.forEach(imp => imports!.add(imp)); + } + } + } + } + + return {decorators, componentImports}; +} + +/** + * Finds the class declarations that are being referred + * to in the `declarations` of an object literal. + * @param obj Object literal that may contain the declarations. + * @param typeChecker + */ +function extractDeclarationsFromTestObject( + obj: ts.ObjectLiteralExpression, typeChecker: ts.TypeChecker): ts.ClassDeclaration[] { + const results: ts.ClassDeclaration[] = []; + const declarations = findLiteralProperty(obj, 'declarations'); + + if (declarations && hasNgModuleMetadataElements(declarations)) { + for (const element of declarations.initializer.elements) { + const declaration = findClassDeclaration(element, typeChecker); + + // Note that we only migrate classes that are in the same file as the testing module, + // because external fixture components are somewhat rare and handling them is going + // to involve a lot of assumptions that are likely to be incorrect. + if (declaration && declaration.getSourceFile().fileName === obj.getSourceFile().fileName) { + results.push(declaration); + } + } + } + + return results; +} diff --git a/packages/core/schematics/ng-generate/standalone-migration/util.ts b/packages/core/schematics/ng-generate/standalone-migration/util.ts new file mode 100644 index 00000000000..51a0fdc31dc --- /dev/null +++ b/packages/core/schematics/ng-generate/standalone-migration/util.ts @@ -0,0 +1,256 @@ +/*! + * @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 {NgtscProgram} from '@angular/compiler-cli'; +import {dirname, relative} from 'path'; +import ts from 'typescript'; + +import {ImportManager} from '../../utils/import_manager'; + +/** Mapping between a source file and the changes that have to be applied to it. */ +export type ChangesByFile = ReadonlyMap; + +/** Map used to look up nodes based on their positions in a source file. */ +export type NodeLookup = Map; + +/** Utility to type a class declaration with a name. */ +export type NamedClassDeclaration = ts.ClassDeclaration&{name: ts.Identifier}; + +/** Change that needs to be applied to a file. */ +interface PendingChange { + /** Index at which to start changing the file. */ + start: number; + /** + * Amount of text that should be removed after the `start`. + * No text will be removed if omitted. + */ + removeLength?: number; + /** New text that should be inserted. */ + text: string; +} + +/** Tracks changes that have to be made for specific files. */ +export class ChangeTracker { + private readonly _changes = new Map(); + private readonly _importManager = new ImportManager( + currentFile => ({ + addNewImport: (start, text) => this.insertText(currentFile, start, text), + updateExistingImport: (namedBindings, text) => + this.replaceText(currentFile, namedBindings.getStart(), namedBindings.getWidth(), text), + }), + this._printer); + + constructor(private _printer: ts.Printer) {} + + /** + * Tracks the insertion of some text. + * @param sourceFile File in which the text is being inserted. + * @param start Index at which the text is insert. + * @param text Text to be inserted. + */ + insertText(sourceFile: ts.SourceFile, index: number, text: string): void { + this._trackChange(sourceFile, {start: index, text}); + } + + /** + * Replaces text within a file. + * @param sourceFile File in which to replace the text. + * @param start Index from which to replace the text. + * @param removeLength Length of the text being replaced. + * @param text Text to be inserted instead of the old one. + */ + replaceText(sourceFile: ts.SourceFile, start: number, removeLength: number, text: string): void { + this._trackChange(sourceFile, {start, removeLength, text}); + } + + /** + * Replaces the text of an AST node with a new one. + * @param oldNode Node to be replaced. + * @param newNode New node to be inserted. + * @param emitHint Hint when formatting the text of the new node. + * @param sourceFileWhenPrinting File to use when printing out the new node. This is important + * when copying nodes from one file to another, because TypeScript might not output literal nodes + * without it. + */ + replaceNode( + oldNode: ts.Node, newNode: ts.Node, emitHint = ts.EmitHint.Unspecified, + sourceFileWhenPrinting?: ts.SourceFile): void { + const sourceFile = oldNode.getSourceFile(); + this.replaceText( + sourceFile, oldNode.getStart(), oldNode.getWidth(), + this._printer.printNode(emitHint, newNode, sourceFileWhenPrinting || sourceFile)); + } + + /** + * Removes the text of an AST node from a file. + * @param node Node whose text should be removed. + */ + removeNode(node: ts.Node): void { + this._trackChange( + node.getSourceFile(), {start: node.getStart(), removeLength: node.getWidth(), text: ''}); + } + + /** + * Adds an import to a file. + * @param sourceFile File to which to add the import. + * @param symbolName Symbol being imported. + * @param moduleName Module from which the symbol is imported. + */ + addImport( + sourceFile: ts.SourceFile, symbolName: string, moduleName: string, + alias: string|null = null): ts.Expression { + return this._importManager.addImportToSourceFile(sourceFile, symbolName, moduleName, alias); + } + + /** + * Gets the changes that should be applied to all the files in the migration. + * The changes are sorted in the order in which they should be applied. + */ + recordChanges(): ChangesByFile { + this._importManager.recordChanges(); + return this._changes; + } + + /** + * Adds a change to a `ChangesByFile` map. + * @param file File that the change is associated with. + * @param change Change to be added. + */ + private _trackChange(file: ts.SourceFile, change: PendingChange): void { + const changes = this._changes.get(file); + + if (changes) { + // Insert the changes in reverse so that they're applied in reverse order. + // This ensures that the offsets of subsequent changes aren't affected by + // previous changes changing the file's text. + const insertIndex = changes.findIndex(current => current.start <= change.start); + + if (insertIndex === -1) { + changes.push(change); + } else { + changes.splice(insertIndex, 0, change); + } + } else { + this._changes.set(file, [change]); + } + } +} + +/** Utility class used to track a one-to-many relationship where all the items are unique. */ +export class UniqueItemTracker { + private _nodes = new Map>(); + + track(key: K, item: V) { + const set = this._nodes.get(key); + + if (set) { + set.add(item); + } else { + this._nodes.set(key, new Set([item])); + } + } + + getEntries(): IterableIterator<[K, Set]> { + return this._nodes.entries(); + } +} + + +/** + * Creates a TypeScript language service. + * @param program Program used to analyze the project. + * @param host Compiler host used to interact with the file system. + * @param rootFileNames Root files of the project. + * @param basePath Root path of the project. + */ +export function createLanguageService( + program: NgtscProgram, host: ts.CompilerHost, rootFileNames: string[], + basePath: string): ts.LanguageService { + return ts.createLanguageService({ + getCompilationSettings: () => program.getTsProgram().getCompilerOptions(), + getScriptFileNames: () => rootFileNames, + getScriptVersion: () => '0', // The files won't change so we can return the same version. + getScriptSnapshot: (fileName: string) => { + const content = host.readFile(fileName); + return content ? ts.ScriptSnapshot.fromString(content) : undefined; + }, + getCurrentDirectory: () => basePath, + getDefaultLibFileName: options => ts.getDefaultLibFilePath(options), + readFile: (path: string) => host.readFile(path), + fileExists: (path: string) => host.fileExists(path) + }); +} + +/** Creates a NodeLookup object from a source file. */ +export function getNodeLookup(sourceFile: ts.SourceFile): NodeLookup { + const lookup: NodeLookup = new Map(); + + sourceFile.forEachChild(function walk(node) { + const nodesAtStart = lookup.get(node.getStart()); + + if (nodesAtStart) { + nodesAtStart.push(node); + } else { + lookup.set(node.getStart(), [node]); + } + + node.forEachChild(walk); + }); + + return lookup; +} + +/** + * Converts node offsets to the nodes they correspond to. + * @param lookup Data structure used to look up nodes at particular positions. + * @param offsets Offsets of the nodes. + * @param results Set in which to store the results. + */ +export function offsetsToNodes( + lookup: NodeLookup, offsets: [start: number, end: number][], + results: Set): Set { + for (const [start, end] of offsets) { + const match = lookup.get(start)?.find(node => node.getEnd() === end); + + if (match) { + results.add(match); + } + } + + return results; +} + +/** + * Finds the class declaration that is being referred to by a node. + * @param reference Node referring to a class declaration. + * @param typeChecker + */ +export function findClassDeclaration( + reference: ts.Node, typeChecker: ts.TypeChecker): ts.ClassDeclaration|null { + return typeChecker.getTypeAtLocation(reference).getSymbol()?.declarations?.find( + ts.isClassDeclaration) || + null; +} + +/** Finds a property with a specific name in an object literal expression. */ +export function findLiteralProperty(literal: ts.ObjectLiteralExpression, name: string) { + return literal.properties.find( + prop => prop.name && ts.isIdentifier(prop.name) && prop.name.text === name); +} + +/** Gets a relative path between two files that can be used inside a TypeScript import. */ +export function getRelativeImportPath(fromFile: string, toFile: string): string { + let path = relative(dirname(fromFile), toFile).replace(/\.ts$/, ''); + + // `relative` returns paths inside the same directory without `./` + if (!path.startsWith('.')) { + path = './' + path; + } + + return path; +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index f940896075b..b91c2ba14fb 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -23,6 +23,9 @@ jasmine_node_test( "//packages/core/schematics/migrations/relative-link-resolution:bundle", "//packages/core/schematics/migrations/router-link-with-href", "//packages/core/schematics/migrations/router-link-with-href:bundle", + "//packages/core/schematics/ng-generate/standalone-migration", + "//packages/core/schematics/ng-generate/standalone-migration:bundle", + "//packages/core/schematics/ng-generate/standalone-migration:static_files", ], templated_args = ["--nobazel_run_linker"], deps = [ diff --git a/packages/core/schematics/test/standalone_migration_spec.ts b/packages/core/schematics/test/standalone_migration_spec.ts new file mode 100644 index 00000000000..ec0763930bf --- /dev/null +++ b/packages/core/schematics/test/standalone_migration_spec.ts @@ -0,0 +1,1175 @@ +/** + * @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 {runfiles} from '@bazel/runfiles'; +import shx from 'shelljs'; + +describe('standalone migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + let previousWorkingDir: string; + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration(mode: string, path = './') { + return runner.runSchematic('standalone-migration', {mode, path}, tree); + } + + function stripWhitespace(content: string) { + return content.replace(/\s+/g, ''); + } + + beforeEach(() => { + runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../collection.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({ + version: 1, + projects: {t: {root: '', 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/index.d.ts', ` + export declare class PlatformRef { + bootstrapModule(module: any): any; + } + + export declare function forwardRef(fn: () => T): T; + `); + + writeFile('/node_modules/@angular/platform-browser/index.d.ts', ` + import {PlatformRef} from '@angular/core'; + + export const platformBrowser: () => PlatformRef; + `); + + writeFile('/node_modules/@angular/platform-browser/animations/index.d.ts', ` + import {ModuleWithProviders} from '@angular/core'; + + export declare class BrowserAnimationsModule { + static withConfig(config: any): ModuleWithProviders; + } + + export declare class NoopAnimationsModule {} + `); + + writeFile('/node_modules/@angular/platform-browser-dynamic/index.d.ts', ` + import {PlatformRef} from '@angular/core'; + + export const platformBrowserDynamic: () => PlatformRef; + `); + + writeFile('/node_modules/@angular/common/index.d.ts', ` + import {ɵɵDirectiveDeclaration, ɵɵNgModuleDeclaration} from '@angular/core'; + + export declare class NgIf { + ngIf: any; + ngIfThen: TemplateRef|null; + ngIfElse: TemplateRef|null; + static ɵdir: ɵɵDirectiveDeclaration; + } + + export declare class NgForOf { + ngForOf: any; + + static ɵdir: ɵɵDirectiveDeclaration; + } + + export declare class CommonModule { + static ɵmod: ɵɵNgModuleDeclaration; + } + `); + + writeFile('/node_modules/@angular/router/index.d.ts', ` + import {ModuleWithProviders} from '@angular/core'; + + export declare class RouterModule { + static forRoot(routes: any[]): ModuleWithProviders; + } + `); + + writeFile('/node_modules/@angular/core/testing/index.d.ts', ` + export declare class TestBed { + static configureTestingModule(config: any): any; + } + `); + + writeFile('/node_modules/some_internal_path/angular/testing/catalyst/index.d.ts', ` + export declare function setupModule(config: 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 throw an error if no files match the passed-in path', async () => { + let error: string|null = null; + + writeFile('dir.ts', ` + import {Directive} from '@angular/core'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + `); + + try { + await runMigration('convert-to-standalone', './foo'); + } catch (e: any) { + error = e.message; + } + + expect(error).toMatch( + /Could not find any files to migrate under the path .*\/foo\. Cannot run the standalone migration/); + }); + + it('should throw an error if a path outside of the project is passed in', async () => { + let error: string|null = null; + + writeFile('dir.ts', ` + import {Directive} from '@angular/core'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + `); + + try { + await runMigration('convert-to-standalone', '../foo'); + } catch (e: any) { + error = e.message; + } + + expect(error).toBe('Cannot run standalone migration outside of the current project.'); + }); + + it('should throw an error if an unknown mode is passed in', async () => { + let error: string|null = null; + + writeFile('dir.ts', 'console.log(123)'); + + try { + await runMigration('does-not-exist', './'); + } catch (e: any) { + error = e.message; + } + + expect(error).toBe( + 'Unknown schematic mode does-not-exist. Cannot run the standalone migration.'); + }); + + it('should throw an error if the passed in path is a file', async () => { + let error: string|null = null; + + writeFile('dir.ts', ''); + + try { + await runMigration('convert-to-standalone', './dir.ts'); + } catch (e: any) { + error = e.message; + } + + expect(error).toMatch( + /Migration path .*\/dir\.ts has to be a directory\. Cannot run the standalone migration/); + }); + + it('should create an `imports` array if the module does not have one already', async () => { + writeFile('module.ts', ` + import {NgModule, Directive} from '@angular/core'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + + @NgModule({declarations: [MyDir]}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace(`@NgModule({imports: [MyDir]})`)); + }); + + it('should combine the `declarations` array with a static `imports` array', async () => { + writeFile('module.ts', ` + import {NgModule, Directive} from '@angular/core'; + import {CommonModule} from '@angular/common'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + + @NgModule({declarations: [MyDir], imports: [CommonModule]}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace(`@NgModule({imports: [CommonModule, MyDir]})`)); + }); + + it('should combine a `declarations` array with a spread expression into the `imports`', + async () => { + writeFile('module.ts', ` + import {NgModule, Directive} from '@angular/core'; + import {CommonModule} from '@angular/common'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + + @Directive({selector: '[dir]'}) + export class MyOtherDir {} + + const extraDeclarations = [MyOtherDir]; + + @NgModule({declarations: [MyDir, ...extraDeclarations], imports: [CommonModule]}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace( + `@NgModule({imports: [CommonModule, MyDir, ...extraDeclarations]})`)); + }); + + it('should combine a `declarations` array with an `imports` array that has a spread expression', + async () => { + writeFile('module.ts', ` + import {NgModule, Directive} from '@angular/core'; + import {CommonModule} from '@angular/common'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + + @Directive({selector: '[dir]', standalone: true}) + export class MyOtherDir {} + + const extraImports = [MyOtherDir]; + + @NgModule({declarations: [MyDir], imports: [CommonModule, ...extraImports]}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain( + stripWhitespace(`@NgModule({imports: [CommonModule, ...extraImports, MyDir]})`)); + }); + + it('should use a spread expression if the `declarations` is an expression when combining with the `imports`', + async () => { + writeFile('module.ts', ` + import {NgModule, Directive} from '@angular/core'; + import {CommonModule} from '@angular/common'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + + const DECLARATIONS = [MyDir]; + + @NgModule({declarations: DECLARATIONS, imports: [CommonModule]}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace(`@NgModule({imports: [CommonModule, ...DECLARATIONS]})`)); + }); + + it('should use a spread expression if the `imports` is an expression when combining with the `declarations`', + async () => { + writeFile('module.ts', ` + import {NgModule, Directive} from '@angular/core'; + import {CommonModule} from '@angular/common'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + + const IMPORTS = [CommonModule]; + + @NgModule({declarations: [MyDir], imports: IMPORTS}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace(`@NgModule({imports: [...IMPORTS, MyDir]})`)); + }); + + it('should use a spread expression if both the `declarations` and the `imports` are not static arrays', + async () => { + writeFile('module.ts', ` + import {NgModule, Directive} from '@angular/core'; + import {CommonModule} from '@angular/common'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + + const IMPORTS = [CommonModule]; + const DECLARATIONS = [MyDir]; + + @NgModule({declarations: DECLARATIONS, imports: IMPORTS}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace(`@NgModule({imports: [...IMPORTS, ...DECLARATIONS]})`)); + }); + + it('should convert a directive in the same file as its module to standalone', async () => { + writeFile('module.ts', ` + import {NgModule, Directive} from '@angular/core'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + + @NgModule({declarations: [MyDir], exports: [MyDir]}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + const result = tree.readContent('module.ts'); + + expect(stripWhitespace(result)) + .toContain(stripWhitespace(`@Directive({selector: '[dir]', standalone: true})`)); + expect(stripWhitespace(result)) + .toContain(stripWhitespace(`@NgModule({imports: [MyDir], exports: [MyDir]})`)); + }); + + it('should convert a pipe in the same file as its module to standalone', async () => { + writeFile('module.ts', ` + import {NgModule, Pipe} from '@angular/core'; + + @Pipe({name: 'myPipe'}) + export class MyPipe {} + + @NgModule({declarations: [MyPipe], exports: [MyPipe]}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + const result = tree.readContent('module.ts'); + + expect(stripWhitespace(result)) + .toContain(stripWhitespace(`@Pipe({name: 'myPipe', standalone: true})`)); + expect(stripWhitespace(result)) + .toContain(stripWhitespace(`@NgModule({imports: [MyPipe], exports: [MyPipe]})`)); + }); + + it('should only migrate declarations under a specific path', async () => { + const content = ` + import {NgModule, Directive} from '@angular/core'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + + @NgModule({declarations: [MyDir], exports: [MyDir]}) + export class Mod {} + `; + + writeFile('./apps/app-1/module.ts', content); + writeFile('./apps/app-2/module.ts', content); + + await runMigration('convert-to-standalone', './apps/app-2'); + + expect(tree.readContent('./apps/app-1/module.ts')).not.toContain('standalone'); + expect(tree.readContent('./apps/app-2/module.ts')).toContain('standalone: true'); + }); + + it('should convert a directive in a different file from its module to standalone', async () => { + writeFile('module.ts', ` + import {NgModule} from '@angular/core'; + import {MyDir} from './dir'; + + @NgModule({declarations: [MyDir], exports: [MyDir]}) + export class Mod {} + `); + + writeFile('dir.ts', ` + import {Directive} from '@angular/core'; + + @Directive({selector: '[dir]'}) + export class MyDir {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace(`@NgModule({imports: [MyDir], exports: [MyDir]})`)); + expect(stripWhitespace(tree.readContent('dir.ts'))) + .toContain(stripWhitespace(`@Directive({selector: '[dir]', standalone: true})`)); + }); + + it('should convert a component with no template dependencies to standalone', async () => { + writeFile('module.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './comp'; + + @NgModule({declarations: [MyComp], exports: [MyComp]}) + export class Mod {} + `); + + writeFile('comp.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-comp', template: '

Hello

'}) + export class MyComp {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace(`@NgModule({imports: [MyComp], exports: [MyComp]})`)); + expect(stripWhitespace(tree.readContent('comp.ts'))).toContain(stripWhitespace(` + @Component({ + selector: 'my-comp', + template: '

Hello

', + standalone: true + }) + `)); + }); + + it('should add imports to dependencies within the same module', async () => { + writeFile('module.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './comp'; + import {MyButton} from './button'; + import {MyTooltip} from './tooltip'; + + @NgModule({declarations: [MyComp, MyButton, MyTooltip], exports: [MyComp]}) + export class Mod {} + `); + + writeFile('comp.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-comp', template: 'Hello'}) + export class MyComp {} + `); + + writeFile('button.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-button', template: ''}) + export class MyButton {} + `); + + writeFile('tooltip.ts', ` + import {Directive} from '@angular/core'; + + @Directive({selector: '[tooltip]'}) + export class MyTooltip {} + `); + + await runMigration('convert-to-standalone'); + + const myCompContent = tree.readContent('comp.ts'); + + expect(myCompContent).toContain(`import { MyButton } from "./button";`); + expect(myCompContent).toContain(`import { MyTooltip } from "./tooltip";`); + expect(stripWhitespace(myCompContent)).toContain(stripWhitespace(` + @Component({ + selector: 'my-comp', + template: 'Hello', + standalone: true, + imports: [MyButton, MyTooltip] + }) + `)); + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace( + `@NgModule({imports: [MyComp, MyButton, MyTooltip], exports: [MyComp]})`)); + expect(stripWhitespace(tree.readContent('button.ts'))) + .toContain(stripWhitespace( + `@Component({selector: 'my-button', template: '', standalone: true})`)); + expect(stripWhitespace(tree.readContent('tooltip.ts'))) + .toContain(stripWhitespace(`@Directive({selector: '[tooltip]', standalone: true})`)); + }); + + it('should reuse existing import statements when adding imports to a component', async () => { + writeFile('module.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './comp'; + import {MyButton} from './button'; + + @NgModule({declarations: [MyComp, MyButton], exports: [MyComp]}) + export class Mod {} + `); + + writeFile('comp.ts', ` + import {Component} from '@angular/core'; + import {helper} from './button'; + + helper(); + + @Component({selector: 'my-comp', template: 'Hello'}) + export class MyComp {} + `); + + writeFile('button.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-button', template: ''}) + export class MyButton {} + + export function helper() {} + `); + + await runMigration('convert-to-standalone'); + + const myCompContent = tree.readContent('comp.ts'); + + expect(myCompContent).toContain(`import { helper, MyButton } from './button';`); + expect(stripWhitespace(myCompContent)).toContain(stripWhitespace(` + @Component({ + selector: 'my-comp', + template: 'Hello', + standalone: true, + imports: [MyButton] + }) + `)); + }); + + it('should refer to pre-existing standalone dependencies directly when adding to the `imports`', + async () => { + writeFile('module.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './comp'; + import {MyButton} from './button'; + + @NgModule({imports: [MyButton], declarations: [MyComp], exports: [MyComp]}) + export class Mod {} + `); + + writeFile('comp.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-comp', template: 'Hello'}) + export class MyComp {} + `); + + writeFile('button.ts', ` + import {Component} from '@angular/core'; + import {MyComp} from './comp'; + + @Component({selector: 'my-button', template: '', standalone: true}) + export class MyButton {} + `); + + await runMigration('convert-to-standalone'); + const myCompContent = tree.readContent('comp.ts'); + + expect(myCompContent).toContain(`import { MyButton } from "./button";`); + expect(stripWhitespace(myCompContent)).toContain(stripWhitespace(` + @Component({ + selector: 'my-comp', + template: 'Hello', + standalone: true, + imports: [MyButton] + }) + `)); + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain( + stripWhitespace('@NgModule({imports: [MyButton, MyComp], exports: [MyComp]})')); + }); + + it('should refer to dependencies being handled in the same migration directly', async () => { + writeFile('module.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './comp'; + import {ButtonModule} from './button.module'; + + @NgModule({imports: [ButtonModule], declarations: [MyComp], exports: [MyComp]}) + export class Mod {} + `); + + writeFile('button.module.ts', ` + import {NgModule} from '@angular/core'; + import {MyButton} from './button'; + + @NgModule({declarations: [MyButton], exports: [MyButton]}) + export class ButtonModule {} + `); + + writeFile('comp.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-comp', template: 'Hello'}) + export class MyComp {} + `); + + writeFile('button.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-button', template: ''}) + export class MyButton {} + `); + + await runMigration('convert-to-standalone'); + + const myCompContent = tree.readContent('comp.ts'); + + expect(myCompContent).toContain(`import { MyButton } from "./button";`); + expect(stripWhitespace(myCompContent)).toContain(stripWhitespace(` + @Component({ + selector: 'my-comp', + template: 'Hello', + standalone: true, + imports: [MyButton] + }) + `)); + expect(stripWhitespace(tree.readContent('button.ts'))).toContain(stripWhitespace(` + @Component({ + selector: 'my-button', + template: '', + standalone: true + }) + `)); + expect(stripWhitespace(tree.readContent('module.ts'))).toContain(stripWhitespace(` + @NgModule({imports: [ButtonModule, MyComp], exports: [MyComp]}) + `)); + expect(stripWhitespace(tree.readContent('button.module.ts'))).toContain(stripWhitespace(` + @NgModule({imports: [MyButton], exports: [MyButton]}) + `)); + }); + + it('should refer to dependencies by their module if they have been excluded from the migration', + async () => { + writeFile('./should-migrate/module.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './comp'; + import {ButtonModule} from '../do-not-migrate/button.module'; + + @NgModule({imports: [ButtonModule], declarations: [MyComp], exports: [MyComp]}) + export class Mod {} + `); + + writeFile('./do-not-migrate/button.module.ts', ` + import {NgModule} from '@angular/core'; + import {MyButton} from './button'; + + @NgModule({declarations: [MyButton], exports: [MyButton]}) + export class ButtonModule {} + `); + + writeFile('./should-migrate/comp.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-comp', template: 'Hello'}) + export class MyComp {} + `); + + writeFile('./do-not-migrate/button.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-button', template: ''}) + export class MyButton {} + `); + + await runMigration('convert-to-standalone', './should-migrate'); + + const myCompContent = tree.readContent('./should-migrate/comp.ts'); + + expect(myCompContent) + .toContain(`import { ButtonModule } from "../do-not-migrate/button.module";`); + expect(stripWhitespace(myCompContent)).toContain(stripWhitespace(` + @Component({ + selector: 'my-comp', + template: 'Hello', + standalone: true, + imports: [ButtonModule] + }) + `)); + expect(stripWhitespace(tree.readContent('./should-migrate/module.ts'))) + .toContain( + stripWhitespace(`@NgModule({imports: [ButtonModule, MyComp], exports: [MyComp]})`)); + expect(tree.readContent('./do-not-migrate/button.ts')).not.toContain('standalone'); + expect(stripWhitespace(tree.readContent('./do-not-migrate/button.module.ts'))) + .toContain( + stripWhitespace(`@NgModule({declarations: [MyButton], exports: [MyButton]})`)); + }); + + it('should add imports to dependencies within the same module', async () => { + writeFile('module.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './comp'; + import {MyButton} from './button'; + import {MyTooltip} from './tooltip'; + + @NgModule({declarations: [MyComp, MyButton, MyTooltip], exports: [MyComp]}) + export class Mod {} + `); + + writeFile('comp.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-comp', template: 'Hello'}) + export class MyComp {} + `); + + writeFile('button.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-button', template: ''}) + export class MyButton {} + `); + + writeFile('tooltip.ts', ` + import {Directive} from '@angular/core'; + + @Directive({selector: '[tooltip]'}) + export class MyTooltip {} + `); + + await runMigration('convert-to-standalone'); + + const myCompContent = tree.readContent('comp.ts'); + + expect(myCompContent).toContain(`import { MyButton } from "./button";`); + expect(myCompContent).toContain(`import { MyTooltip } from "./tooltip";`); + expect(stripWhitespace(myCompContent)).toContain(stripWhitespace(` + @Component({ + selector: 'my-comp', + template: 'Hello', + standalone: true, + imports: [MyButton, MyTooltip] + }) + `)); + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace( + `@NgModule({imports: [MyComp, MyButton, MyTooltip], exports: [MyComp]})`)); + expect(stripWhitespace(tree.readContent('button.ts'))) + .toContain(stripWhitespace( + `@Component({selector: 'my-button', template: '', standalone: true})`)); + expect(stripWhitespace(tree.readContent('tooltip.ts'))) + .toContain(stripWhitespace(`@Directive({selector: '[tooltip]', standalone: true})`)); + }); + + it('should add imports to external dependencies', async () => { + writeFile('module.ts', ` + import {NgModule} from '@angular/core'; + import {CommonModule} from '@angular/common'; + import {MyComp, MyOtherComp} from './comp'; + + @NgModule({imports: [CommonModule], declarations: [MyComp, MyOtherComp], exports: [MyComp]}) + export class Mod {} + `); + + writeFile('comp.ts', ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'my-comp', + template: \` +
+ {{message}} +
+ \` + }) + export class MyComp { + messages = ['hello', 'hi']; + } + + @Component({ + selector: 'my-other-comp', + template: '
' + }) + export class MyOtherComp { + isShown = true; + } + `); + + await runMigration('convert-to-standalone'); + + const myCompContent = tree.readContent('comp.ts'); + + expect(myCompContent).toContain(`import { NgForOf, NgIf } from "@angular/common";`); + expect(stripWhitespace(myCompContent)).toContain(stripWhitespace(` + @Component({ + selector: 'my-comp', + template: \` +
+ {{message}} +
+ \`, + standalone: true, + imports: [NgForOf, NgIf] + }) + `)); + expect(stripWhitespace(myCompContent)).toContain(stripWhitespace(` + @Component({ + selector: 'my-other-comp', + template: '
', + standalone: true, + imports: [NgIf] + }) + `)); + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace( + `@NgModule({imports: [CommonModule, MyComp, MyOtherComp], exports: [MyComp]})`)); + }); + + it('should add imports to pipes that are used in the template', async () => { + writeFile('module.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './comp'; + import {MyPipe} from './pipe'; + + @NgModule({declarations: [MyComp, MyPipe], exports: [MyComp]}) + export class Mod {} + `); + + writeFile('comp.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-comp', template: '{{"hello" | myPipe}}'}) + export class MyComp {} + `); + + writeFile('pipe.ts', ` + import {Pipe} from '@angular/core'; + + @Pipe({name: 'myPipe'}) + export class MyPipe { + transform() {} + } + `); + + await runMigration('convert-to-standalone'); + + const myCompContent = tree.readContent('comp.ts'); + + expect(myCompContent).toContain(`import { MyPipe } from "./pipe";`); + expect(stripWhitespace(myCompContent)).toContain(stripWhitespace(` + @Component({ + selector: 'my-comp', + template: '{{"hello" | myPipe}}', + standalone: true, + imports: [MyPipe] + }) + `)); + expect(stripWhitespace(tree.readContent('module.ts'))) + .toContain(stripWhitespace(`@NgModule({imports: [MyComp, MyPipe], exports: [MyComp]})`)); + expect(stripWhitespace(tree.readContent('pipe.ts'))) + .toContain(stripWhitespace(`@Pipe({name: 'myPipe', standalone: true})`)); + }); + + it('should migrate tests with an inline NgModule', async () => { + writeFile('app.spec.ts', ` + import {NgModule, Component} from '@angular/core'; + import {TestBed} from '@angular/core/testing'; + + describe('bootrstrapping an app', () => { + it('should work', () => { + @Component({selector: 'hello', template: 'Hello'}) + class Hello {} + + @Component({template: ''}) + class App {} + + @NgModule({declarations: [App, Hello], exports: [App, Hello]}) + class Mod {} + + TestBed.configureTestingModule({imports: [Mod]}); + const fixture = TestBed.createComponent(App); + expect(fixture.nativeElement.innerHTML).toBe('Hello'); + }); + }); + `); + + await runMigration('convert-to-standalone'); + + const content = stripWhitespace(tree.readContent('app.spec.ts')); + + expect(content).toContain(stripWhitespace(` + @Component({selector: 'hello', template: 'Hello', standalone: true}) + class Hello {} + `)); + + expect(content).toContain(stripWhitespace(` + @Component({template: '', standalone: true, imports: [Hello]}) + class App {} + `)); + + expect(content).toContain(stripWhitespace(` + @NgModule({imports: [App, Hello], exports: [App, Hello]}) + class Mod {} + `)); + }); + + it('should import the module that declares a template dependency', async () => { + writeFile('./should-migrate/module.ts', ` + import {NgModule} from '@angular/core'; + import {MyComp} from './comp'; + import {ButtonModule} from '../do-not-migrate/button.module'; + + @NgModule({imports: [ButtonModule], declarations: [MyComp]}) + export class Mod {} + `); + + writeFile('./should-migrate/comp.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-comp', template: 'Hello'}) + export class MyComp {} + `); + + writeFile('./do-not-migrate/button.module.ts', ` + import {NgModule, forwardRef} from '@angular/core'; + import {MyButton} from './button'; + + @NgModule({ + imports: [forwardRef(() => ButtonModule)], + exports: [forwardRef(() => ButtonModule)] + }) + export class ExporterModule {} + + @NgModule({declarations: [MyButton], exports: [MyButton]}) + export class ButtonModule {} + `); + + writeFile('./do-not-migrate/button.ts', ` + import {Component} from '@angular/core'; + + @Component({selector: 'my-button', template: ''}) + export class MyButton {} + `); + + await runMigration('convert-to-standalone', './should-migrate'); + + const myCompContent = tree.readContent('./should-migrate/comp.ts'); + expect(myCompContent) + .toContain(`import { ButtonModule } from "../do-not-migrate/button.module";`); + expect(myCompContent).toContain('imports: [ButtonModule]'); + }); + + it('should migrate tests with a component declared through TestBed', async () => { + writeFile('app.spec.ts', ` + import {NgModule, Component} from '@angular/core'; + import {TestBed} from '@angular/core/testing'; + import {ButtonModule} from './button.module'; + import {MatCardModule} from '@angular/material/card'; + + describe('bootrstrapping an app', () => { + it('should work', () => { + TestBed.configureTestingModule({ + declarations: [App, Hello], + imports: [ButtonModule, MatCardModule] + }); + const fixture = TestBed.createComponent(App); + expect(fixture.nativeElement.innerHTML).toBe('Hello'); + }); + + it('should work in a different way', () => { + TestBed.configureTestingModule({declarations: [App, Hello], imports: [MatCardModule]}); + const fixture = TestBed.createComponent(App); + expect(fixture.nativeElement.innerHTML).toBe('Hello'); + }); + }); + + @Component({selector: 'hello', template: 'Hello'}) + class Hello {} + + @Component({template: ''}) + class App {} + `); + + await runMigration('convert-to-standalone'); + + const content = stripWhitespace(tree.readContent('app.spec.ts')); + + expect(content).toContain(stripWhitespace(` + @Component({ + selector: 'hello', + template: 'Hello', + standalone: true, + imports: [ButtonModule, MatCardModule] + }) + class Hello {} + `)); + + expect(content).toContain(stripWhitespace(` + @Component({ + template: '', + standalone: true, + imports: [ButtonModule, MatCardModule] + }) + class App {} + `)); + + expect(content).toContain(stripWhitespace(` + it('should work', () => { + TestBed.configureTestingModule({ + imports: [ButtonModule, MatCardModule, App, Hello] + }); + const fixture = TestBed.createComponent(App); + expect(fixture.nativeElement.innerHTML).toBe('Hello'); + }); + `)); + + expect(content).toContain(stripWhitespace(` + it('should work in a different way', () => { + TestBed.configureTestingModule({imports: [MatCardModule, App, Hello]}); + const fixture = TestBed.createComponent(App); + expect(fixture.nativeElement.innerHTML).toBe('Hello'); + }); + `)); + }); + + it('should migrate tests with a component declared through Catalyst', async () => { + writeFile('app.spec.ts', ` + import {NgModule, Component} from '@angular/core'; + import {bootstrap, setupModule} from 'some_internal_path/angular/testing/catalyst'; + import {ButtonModule} from './button.module'; + import {MatCardModule} from '@angular/material/card'; + + describe('bootrstrapping an app', () => { + it('should work', () => { + setupModule({ + declarations: [App, Hello], + imports: [ButtonModule, MatCardModule] + }); + const fixture = bootstrap(App); + expect(fixture.nativeElement.innerHTML).toBe('Hello'); + }); + + it('should work in a different way', () => { + setupModule({declarations: [App, Hello], imports: [MatCardModule]}); + const fixture = bootstrap(App); + expect(fixture.nativeElement.innerHTML).toBe('Hello'); + }); + }); + + @Component({selector: 'hello', template: 'Hello'}) + class Hello {} + + @Component({template: ''}) + class App {} + `); + + await runMigration('convert-to-standalone'); + + const content = stripWhitespace(tree.readContent('app.spec.ts')); + + expect(content).toContain(stripWhitespace(` + @Component({ + selector: 'hello', + template: 'Hello', + standalone: true, + imports: [ButtonModule, MatCardModule] + }) + class Hello {} + `)); + + expect(content).toContain(stripWhitespace(` + @Component({ + template: '', + standalone: true, + imports: [ButtonModule, MatCardModule] + }) + class App {} + `)); + + expect(content).toContain(stripWhitespace(` + it('should work', () => { + setupModule({ + imports: [ButtonModule, MatCardModule, App, Hello] + }); + const fixture = bootstrap(App); + expect(fixture.nativeElement.innerHTML).toBe('Hello'); + }); + `)); + + expect(content).toContain(stripWhitespace(` + it('should work in a different way', () => { + setupModule({imports: [MatCardModule, App, Hello]}); + const fixture = bootstrap(App); + expect(fixture.nativeElement.innerHTML).toBe('Hello'); + }); + `)); + }); + + it('should not migrate modules with a `bootstrap` array', async () => { + const initialModule = ` + import {NgModule, Component} from '@angular/core'; + + @Component({selector: 'root-comp', template: 'hello'}) + export class RootComp {} + + @NgModule({declarations: [RootComp], bootstrap: [RootComp]}) + export class Mod {} + `; + + writeFile('module.ts', initialModule); + + await runMigration('convert-to-standalone'); + + expect(tree.readContent('module.ts')).toBe(initialModule); + }); + + it('should migrate a module with an empty `bootstrap` array', async () => { + writeFile('module.ts', ` + import {NgModule, Component} from '@angular/core'; + + @Component({selector: 'root-comp', template: 'hello'}) + export class RootComp {} + + @NgModule({declarations: [RootComp], bootstrap: []}) + export class Mod {} + `); + + await runMigration('convert-to-standalone'); + + expect(stripWhitespace(tree.readContent('module.ts'))).toBe(stripWhitespace(` + import {NgModule, Component} from '@angular/core'; + + @Component({selector: 'root-comp', template: 'hello', standalone: true}) + export class RootComp {} + + @NgModule({imports: [RootComp], bootstrap: []}) + export class Mod {} + `)); + }); +});