From bff634fe0938ecb4a316064ba3f1b9c2c1f208fe Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sat, 1 Apr 2023 09:09:59 +0200 Subject: [PATCH] fix(@schematics/angular): update private Components utilities to work with standalone project structure When the utilities for adding providers to the `bootstrapApplication` call were written, the structure for a standalone project handn't been finalized yet which meant that the code made some assumptions around how the app config is being passed in. This code breaks with the final project structure, because there the app config is in a separate file instead of an object literal. These changes update the utilities to account for the new structure. Note the updates are meant to cover the majority of apps and there are likely some edge cases that aren't covered. Covering all cases is tricky with the current setup, because to do so correctly would require knowledge of the entire app whereas the utilities operate on a file-by-file basis. For example, now the code is able to update a config in a separate file, but it wouldn't be able to handle a config that is re-exported. For such a case to be supported, we would need access to the type checker. These edge cases will be addressed later on with a larger refactor of the schematics utilities. I've added a couple of extra utilities for inserting function calls into the `providers` array and checking if a function is called in the `providers` array. We'll need them for the `ng generate` schematics on Components. --- .../schematics/angular/private/components.ts | 2 + .../schematics/angular/private/standalone.ts | 524 ++++++++++++++---- .../angular/private/standalone_spec.ts | 395 +++++++++++++ 3 files changed, 798 insertions(+), 123 deletions(-) diff --git a/packages/schematics/angular/private/components.ts b/packages/schematics/angular/private/components.ts index 3365c0bb353d..d679f1cc35da 100644 --- a/packages/schematics/angular/private/components.ts +++ b/packages/schematics/angular/private/components.ts @@ -8,6 +8,8 @@ export { addModuleImportToStandaloneBootstrap, + addFunctionalProvidersToStandaloneBootstrap, + callsProvidersFunction, findBootstrapApplicationCall, importsProvidersFrom, } from './standalone'; diff --git a/packages/schematics/angular/private/standalone.ts b/packages/schematics/angular/private/standalone.ts index b171b8ae7597..e2df6ae8884b 100644 --- a/packages/schematics/angular/private/standalone.ts +++ b/packages/schematics/angular/private/standalone.ts @@ -6,11 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ -import { SchematicsException, Tree } from '@angular-devkit/schematics'; +import { SchematicsException, Tree, UpdateRecorder } from '@angular-devkit/schematics'; +import { dirname, join } from 'path'; import ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { insertImport } from '../utility/ast-utils'; import { InsertChange } from '../utility/change'; +/** App config that was resolved to its source node. */ +interface ResolvedAppConfig { + /** Tree-relative path of the file containing the app config. */ + filePath: string; + + /** Node defining the app config. */ + node: ts.ObjectLiteralExpression; +} + /** * Checks whether the providers from a module are being imported in a `bootstrapApplication` call. * @param tree File tree of the project. @@ -18,19 +28,37 @@ import { InsertChange } from '../utility/change'; * @param className Class name of the module to search for. */ export function importsProvidersFrom(tree: Tree, filePath: string, className: string): boolean { - const sourceFile = ts.createSourceFile( - filePath, - tree.readText(filePath), - ts.ScriptTarget.Latest, - true, + const sourceFile = createSourceFile(tree, filePath); + const bootstrapCall = findBootstrapApplicationCall(sourceFile); + const appConfig = bootstrapCall ? findAppConfig(bootstrapCall, tree, filePath) : null; + const importProvidersFromCall = appConfig ? findImportProvidersFromCall(appConfig.node) : null; + + return !!importProvidersFromCall?.arguments.some( + (arg) => ts.isIdentifier(arg) && arg.text === className, ); +} +/** + * Checks whether a providers function is being called in a `bootstrapApplication` call. + * @param tree File tree of the project. + * @param filePath Path of the file in which to check. + * @param functionName Name of the function to search for. + */ +export function callsProvidersFunction( + tree: Tree, + filePath: string, + functionName: string, +): boolean { + const sourceFile = createSourceFile(tree, filePath); const bootstrapCall = findBootstrapApplicationCall(sourceFile); - const importProvidersFromCall = bootstrapCall ? findImportProvidersFromCall(bootstrapCall) : null; + const appConfig = bootstrapCall ? findAppConfig(bootstrapCall, tree, filePath) : null; + const providersLiteral = appConfig ? findProvidersLiteral(appConfig.node) : null; - return ( - !!importProvidersFromCall && - importProvidersFromCall.arguments.some((arg) => ts.isIdentifier(arg) && arg.text === className) + return !!providersLiteral?.elements.some( + (el) => + ts.isCallExpression(el) && + ts.isIdentifier(el.expression) && + el.expression.text === functionName, ); } @@ -47,86 +75,163 @@ export function addModuleImportToStandaloneBootstrap( moduleName: string, modulePath: string, ) { - const sourceFile = ts.createSourceFile( - filePath, - tree.readText(filePath), - ts.ScriptTarget.Latest, - true, - ); - + const sourceFile = createSourceFile(tree, filePath); const bootstrapCall = findBootstrapApplicationCall(sourceFile); + const addImports = (file: ts.SourceFile, recorder: UpdateRecorder) => { + const sourceText = file.getText(); + + [ + insertImport(file, sourceText, moduleName, modulePath), + insertImport(file, sourceText, 'importProvidersFrom', '@angular/core'), + ].forEach((change) => { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + }); + }; if (!bootstrapCall) { throw new SchematicsException(`Could not find bootstrapApplication call in ${filePath}`); } - const recorder = tree.beginUpdate(filePath); - const importCall = findImportProvidersFromCall(bootstrapCall); - const printer = ts.createPrinter(); - const sourceText = sourceFile.getText(); - - // Add imports to the module being added and `importProvidersFrom`. We don't - // have to worry about duplicates, because `insertImport` handles them. - [ - insertImport(sourceFile, sourceText, moduleName, modulePath), - insertImport(sourceFile, sourceText, 'importProvidersFrom', '@angular/core'), - ].forEach((change) => { - if (change instanceof InsertChange) { - recorder.insertLeft(change.pos, change.toAdd); - } - }); + const importProvidersCall = ts.factory.createCallExpression( + ts.factory.createIdentifier('importProvidersFrom'), + [], + [ts.factory.createIdentifier(moduleName)], + ); + + // If there's only one argument, we have to create a new object literal. + if (bootstrapCall.arguments.length === 1) { + const recorder = tree.beginUpdate(filePath); + addNewAppConfigToCall(bootstrapCall, importProvidersCall, recorder); + addImports(sourceFile, recorder); + tree.commitUpdate(recorder); + + return; + } + + // If the config is a `mergeApplicationProviders` call, add another config to it. + if (isMergeAppConfigCall(bootstrapCall.arguments[1])) { + const recorder = tree.beginUpdate(filePath); + addNewAppConfigToCall(bootstrapCall.arguments[1], importProvidersCall, recorder); + addImports(sourceFile, recorder); + tree.commitUpdate(recorder); + + return; + } + + // Otherwise attempt to merge into the current config. + const appConfig = findAppConfig(bootstrapCall, tree, filePath); + + if (!appConfig) { + throw new SchematicsException( + `Could not statically analyze config in bootstrapApplication call in ${filePath}`, + ); + } + + const { filePath: configFilePath, node: config } = appConfig; + const recorder = tree.beginUpdate(configFilePath); + const importCall = findImportProvidersFromCall(config); + + addImports(config.getSourceFile(), recorder); - // If there is an `importProvidersFrom` call already, reuse it. if (importCall) { + // If there's an `importProvidersFrom` call already, add the module to it. recorder.insertRight( importCall.arguments[importCall.arguments.length - 1].getEnd(), `, ${moduleName}`, ); - } else if (bootstrapCall.arguments.length === 1) { - // Otherwise if there is no options parameter to `bootstrapApplication`, - // create an object literal with a `providers` array and the import. - const newCall = ts.factory.updateCallExpression( - bootstrapCall, - bootstrapCall.expression, - bootstrapCall.typeArguments, - [ - ...bootstrapCall.arguments, - ts.factory.createObjectLiteralExpression([createProvidersAssignment(moduleName)], true), - ], - ); - - recorder.remove(bootstrapCall.getStart(), bootstrapCall.getWidth()); - recorder.insertRight( - bootstrapCall.getStart(), - printer.printNode(ts.EmitHint.Unspecified, newCall, sourceFile), - ); } else { - const providersLiteral = findProvidersLiteral(bootstrapCall); + const providersLiteral = findProvidersLiteral(config); if (providersLiteral) { // If there's a `providers` array, add the import to it. - const newProvidersLiteral = ts.factory.updateArrayLiteralExpression(providersLiteral, [ - ...providersLiteral.elements, - createImportProvidersFromCall(moduleName), - ]); - recorder.remove(providersLiteral.getStart(), providersLiteral.getWidth()); - recorder.insertRight( - providersLiteral.getStart(), - printer.printNode(ts.EmitHint.Unspecified, newProvidersLiteral, sourceFile), - ); + addElementToArray(providersLiteral, importProvidersCall, recorder); } else { // Otherwise add a `providers` array to the existing object literal. - const optionsLiteral = bootstrapCall.arguments[1] as ts.ObjectLiteralExpression; - const newOptionsLiteral = ts.factory.updateObjectLiteralExpression(optionsLiteral, [ - ...optionsLiteral.properties, - createProvidersAssignment(moduleName), - ]); - recorder.remove(optionsLiteral.getStart(), optionsLiteral.getWidth()); - recorder.insertRight( - optionsLiteral.getStart(), - printer.printNode(ts.EmitHint.Unspecified, newOptionsLiteral, sourceFile), - ); + addProvidersToObjectLiteral(config, importProvidersCall, recorder); + } + } + + tree.commitUpdate(recorder); +} + +/** + * Adds a providers function call to the `bootstrapApplication` call. + * @param tree File tree of the project. + * @param filePath Path to the file that should be updated. + * @param functionName Name of the function that should be called. + * @param importPath Path from which to import the function. + * @param args Arguments to use when calling the function. + */ +export function addFunctionalProvidersToStandaloneBootstrap( + tree: Tree, + filePath: string, + functionName: string, + importPath: string, + args: ts.Expression[] = [], +) { + const sourceFile = createSourceFile(tree, filePath); + const bootstrapCall = findBootstrapApplicationCall(sourceFile); + const addImports = (file: ts.SourceFile, recorder: UpdateRecorder) => { + const change = insertImport(file, file.getText(), functionName, importPath); + + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); } + }; + + if (!bootstrapCall) { + throw new SchematicsException(`Could not find bootstrapApplication call in ${filePath}`); + } + + const providersCall = ts.factory.createCallExpression( + ts.factory.createIdentifier(functionName), + undefined, + args, + ); + + // If there's only one argument, we have to create a new object literal. + if (bootstrapCall.arguments.length === 1) { + const recorder = tree.beginUpdate(filePath); + addNewAppConfigToCall(bootstrapCall, providersCall, recorder); + addImports(sourceFile, recorder); + tree.commitUpdate(recorder); + + return; + } + + // If the config is a `mergeApplicationProviders` call, add another config to it. + if (isMergeAppConfigCall(bootstrapCall.arguments[1])) { + const recorder = tree.beginUpdate(filePath); + addNewAppConfigToCall(bootstrapCall.arguments[1], providersCall, recorder); + addImports(sourceFile, recorder); + tree.commitUpdate(recorder); + + return; + } + + // Otherwise attempt to merge into the current config. + const appConfig = findAppConfig(bootstrapCall, tree, filePath); + + if (!appConfig) { + throw new SchematicsException( + `Could not statically analyze config in bootstrapApplication call in ${filePath}`, + ); + } + + const { filePath: configFilePath, node: config } = appConfig; + const recorder = tree.beginUpdate(configFilePath); + const providersLiteral = findProvidersLiteral(config); + + addImports(config.getSourceFile(), recorder); + + if (providersLiteral) { + // If there's a `providers` array, add the import to it. + addElementToArray(providersLiteral, providersCall, recorder); + } else { + // Otherwise add a `providers` array to the existing object literal. + addProvidersToObjectLiteral(config, providersCall, recorder); } tree.commitUpdate(recorder); @@ -140,17 +245,37 @@ export function findBootstrapApplicationCall(sourceFile: ts.SourceFile): ts.Call '@angular/platform-browser', ); - return localName ? findCall(sourceFile, localName) : null; + if (!localName) { + return null; + } + + let result: ts.CallExpression | null = null; + + sourceFile.forEachChild(function walk(node) { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === localName + ) { + result = node; + } + + if (!result) { + node.forEachChild(walk); + } + }); + + return result; } -/** Find a call to `importProvidersFrom` within a `bootstrapApplication` call. */ -function findImportProvidersFromCall(bootstrapCall: ts.CallExpression): ts.CallExpression | null { - const providersLiteral = findProvidersLiteral(bootstrapCall); +/** Find a call to `importProvidersFrom` within an application config. */ +function findImportProvidersFromCall(config: ts.ObjectLiteralExpression): ts.CallExpression | null { const importProvidersName = findImportLocalName( - bootstrapCall.getSourceFile(), + config.getSourceFile(), 'importProvidersFrom', '@angular/core', ); + const providersLiteral = findProvidersLiteral(config); if (providersLiteral && importProvidersName) { for (const element of providersLiteral.elements) { @@ -168,22 +293,123 @@ function findImportProvidersFromCall(bootstrapCall: ts.CallExpression): ts.CallE return null; } -/** Finds the `providers` array literal within a `bootstrapApplication` call. */ -function findProvidersLiteral(bootstrapCall: ts.CallExpression): ts.ArrayLiteralExpression | null { - // The imports have to be in the second argument of - // the function which has to be an object literal. - if ( - bootstrapCall.arguments.length > 1 && - ts.isObjectLiteralExpression(bootstrapCall.arguments[1]) - ) { - for (const prop of bootstrapCall.arguments[1].properties) { - if ( - ts.isPropertyAssignment(prop) && - ts.isIdentifier(prop.name) && - prop.name.text === 'providers' && - ts.isArrayLiteralExpression(prop.initializer) - ) { - return prop.initializer; +/** Finds the `providers` array literal within an application config. */ +function findProvidersLiteral( + config: ts.ObjectLiteralExpression, +): ts.ArrayLiteralExpression | null { + for (const prop of config.properties) { + if ( + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'providers' && + ts.isArrayLiteralExpression(prop.initializer) + ) { + return prop.initializer; + } + } + + return null; +} + +/** + * Resolves the node that defines the app config from a bootstrap call. + * @param bootstrapCall Call for which to resolve the config. + * @param tree File tree of the project. + * @param filePath File path of the bootstrap call. + */ +function findAppConfig( + bootstrapCall: ts.CallExpression, + tree: Tree, + filePath: string, +): ResolvedAppConfig | null { + if (bootstrapCall.arguments.length > 1) { + const config = bootstrapCall.arguments[1]; + + if (ts.isObjectLiteralExpression(config)) { + return { filePath, node: config }; + } + + if (ts.isIdentifier(config)) { + return resolveAppConfigFromIdentifier(config, tree, filePath); + } + } + + return null; +} + +/** + * Resolves the app config from an identifier referring to it. + * @param identifier Identifier referring to the app config. + * @param tree File tree of the project. + * @param bootstapFilePath Path of the bootstrap call. + */ +function resolveAppConfigFromIdentifier( + identifier: ts.Identifier, + tree: Tree, + bootstapFilePath: string, +): ResolvedAppConfig | null { + const sourceFile = identifier.getSourceFile(); + + for (const node of sourceFile.statements) { + // Only look at relative imports. This will break if the app uses a path + // mapping to refer to the import, but in order to resolve those, we would + // need knowledge about the entire program. + if ( + !ts.isImportDeclaration(node) || + !node.importClause?.namedBindings || + !ts.isNamedImports(node.importClause.namedBindings) || + !ts.isStringLiteralLike(node.moduleSpecifier) || + !node.moduleSpecifier.text.startsWith('.') + ) { + continue; + } + + for (const specifier of node.importClause.namedBindings.elements) { + if (specifier.name.text !== identifier.text) { + continue; + } + + // Look for a variable with the imported name in the file. Note that ideally we would use + // the type checker to resolve this, but we can't because these utilities are set up to + // operate on individual files, not the entire program. + const filePath = join(dirname(bootstapFilePath), node.moduleSpecifier.text + '.ts'); + const importedSourceFile = createSourceFile(tree, filePath); + const resolvedVariable = findAppConfigFromVariableName( + importedSourceFile, + (specifier.propertyName || specifier.name).text, + ); + + if (resolvedVariable) { + return { filePath, node: resolvedVariable }; + } + } + } + + const variableInSameFile = findAppConfigFromVariableName(sourceFile, identifier.text); + + return variableInSameFile ? { filePath: bootstapFilePath, node: variableInSameFile } : null; +} + +/** + * Finds an app config within the top-level variables of a file. + * @param sourceFile File in which to search for the config. + * @param variableName Name of the variable containing the config. + */ +function findAppConfigFromVariableName( + sourceFile: ts.SourceFile, + variableName: string, +): ts.ObjectLiteralExpression | null { + for (const node of sourceFile.statements) { + if (ts.isVariableStatement(node)) { + for (const decl of node.declarationList.declarations) { + if ( + ts.isIdentifier(decl.name) && + decl.name.text === variableName && + decl.initializer && + ts.isObjectLiteralExpression(decl.initializer) + ) { + return decl.initializer; + } } } } @@ -233,45 +459,97 @@ function findImportLocalName( return null; } +/** Creates a source file from a file path within a project. */ +function createSourceFile(tree: Tree, filePath: string): ts.SourceFile { + return ts.createSourceFile(filePath, tree.readText(filePath), ts.ScriptTarget.Latest, true); +} + /** - * Finds a call to a function with a specific name. - * @param rootNode Node from which to start searching. - * @param name Name of the function to search for. + * Creates a new app config object literal and adds it to a call expression as an argument. + * @param call Call to which to add the config. + * @param expression Expression that should inserted into the new config. + * @param recorder Recorder to which to log the change. */ -function findCall(rootNode: ts.Node, name: string): ts.CallExpression | null { - let result: ts.CallExpression | null = null; - - rootNode.forEachChild(function walk(node) { - if ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === name - ) { - result = node; - } +function addNewAppConfigToCall( + call: ts.CallExpression, + expression: ts.Expression, + recorder: UpdateRecorder, +): void { + const newCall = ts.factory.updateCallExpression(call, call.expression, call.typeArguments, [ + ...call.arguments, + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + 'providers', + ts.factory.createArrayLiteralExpression([expression]), + ), + ], + true, + ), + ]); - if (!result) { - node.forEachChild(walk); - } - }); + recorder.remove(call.getStart(), call.getWidth()); + recorder.insertRight( + call.getStart(), + ts.createPrinter().printNode(ts.EmitHint.Unspecified, newCall, call.getSourceFile()), + ); +} - return result; +/** + * Adds an element to an array literal expression. + * @param node Array to which to add the element. + * @param element Element to be added. + * @param recorder Recorder to which to log the change. + */ +function addElementToArray( + node: ts.ArrayLiteralExpression, + element: ts.Expression, + recorder: UpdateRecorder, +): void { + const newLiteral = ts.factory.updateArrayLiteralExpression(node, [...node.elements, element]); + recorder.remove(node.getStart(), node.getWidth()); + recorder.insertRight( + node.getStart(), + ts.createPrinter().printNode(ts.EmitHint.Unspecified, newLiteral, node.getSourceFile()), + ); } -/** Creates an `importProvidersFrom({{moduleName}})` call. */ -function createImportProvidersFromCall(moduleName: string): ts.CallExpression { - return ts.factory.createCallChain( - ts.factory.createIdentifier('importProvidersFrom'), - undefined, - undefined, - [ts.factory.createIdentifier(moduleName)], +/** + * Adds a `providers` property to an object literal. + * @param node Literal to which to add the `providers`. + * @param expression Provider that should be part of the generated `providers` array. + * @param recorder Recorder to which to log the change. + */ +function addProvidersToObjectLiteral( + node: ts.ObjectLiteralExpression, + expression: ts.Expression, + recorder: UpdateRecorder, +) { + const newOptionsLiteral = ts.factory.updateObjectLiteralExpression(node, [ + ...node.properties, + ts.factory.createPropertyAssignment( + 'providers', + ts.factory.createArrayLiteralExpression([expression]), + ), + ]); + recorder.remove(node.getStart(), node.getWidth()); + recorder.insertRight( + node.getStart(), + ts.createPrinter().printNode(ts.EmitHint.Unspecified, newOptionsLiteral, node.getSourceFile()), ); } -/** Creates a `providers: [importProvidersFrom({{moduleName}})]` property assignment. */ -function createProvidersAssignment(moduleName: string): ts.PropertyAssignment { - return ts.factory.createPropertyAssignment( - 'providers', - ts.factory.createArrayLiteralExpression([createImportProvidersFromCall(moduleName)]), +/** Checks whether a node is a call to `mergeApplicationConfig`. */ +function isMergeAppConfigCall(node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node)) { + return false; + } + + const localName = findImportLocalName( + node.getSourceFile(), + 'mergeApplicationConfig', + '@angular/core', ); + + return !!localName && ts.isIdentifier(node.expression) && node.expression.text === localName; } diff --git a/packages/schematics/angular/private/standalone_spec.ts b/packages/schematics/angular/private/standalone_spec.ts index e268b47b36b7..9d0b6c9b2c92 100644 --- a/packages/schematics/angular/private/standalone_spec.ts +++ b/packages/schematics/angular/private/standalone_spec.ts @@ -9,7 +9,9 @@ import { EmptyTree } from '@angular-devkit/schematics'; import ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { + addFunctionalProvidersToStandaloneBootstrap, addModuleImportToStandaloneBootstrap, + callsProvidersFunction, findBootstrapApplicationCall, importsProvidersFrom, } from './standalone'; @@ -134,6 +136,41 @@ describe('standalone utilities', () => { }); }); + describe('callsProvidersFunction', () => { + it('should find that a bootstrapApplication call invokes a specific providers function', () => { + host.create( + '/test.ts', + ` + import { provideAnimations, bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, { + providers: [ + {provide: foo, useValue: 10}, + provideAnimations() + ] + }); + `, + ); + + expect(callsProvidersFunction(host, '/test.ts', 'provideAnimations')).toBe(true); + expect(callsProvidersFunction(host, '/test.ts', 'noopAnimations')).toBe(false); + }); + + it('should return false if there is no bootstrapApplication calls', () => { + host.create( + '/test.ts', + ` + import { AppComponent } from './app.component'; + + console.log(AppComponent); + `, + ); + + expect(callsProvidersFunction(host, '/test.ts', 'foo')).toBe(false); + }); + }); + describe('addModuleImportToStandaloneBootstrap', () => { it('should be able to add a module import to a simple `bootstrapApplication` call', () => { host.create( @@ -259,5 +296,363 @@ describe('standalone utilities', () => { addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); }).toThrowError(/Could not find bootstrapApplication call in \/test\.ts/); }); + + it('should add providers to an imported app config', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { appConfig } from './app/app.config'; + + bootstrapApplication(AppComponent, appConfig); + `, + ); + + host.create( + '/app/app.config.ts', + ` + export const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}] + }; + `, + ); + + addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); + + const content = stripWhitespace(host.readText('/app/app.config.ts')); + + assertContains(content, `import {importProvidersFrom} from '@angular/core';`); + assertContains(content, `import {FooModule} from '@foo/bar';`); + assertContains( + content, + `export const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}, importProvidersFrom(FooModule)] + };`, + ); + }); + + it('should add providers to an app config imported through an alias', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { appConfig as config } from './app/app.config'; + + bootstrapApplication(AppComponent, config); + `, + ); + + host.create( + '/app/app.config.ts', + ` + export const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}] + }; + `, + ); + + addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); + + const content = stripWhitespace(host.readText('/app/app.config.ts')); + + assertContains(content, `import {importProvidersFrom} from '@angular/core';`); + assertContains(content, `import {FooModule} from '@foo/bar';`); + assertContains( + content, + `export const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}, importProvidersFrom(FooModule)] + };`, + ); + }); + + it('should add providers to an app config coming from a variable in the same file', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}] + }; + + bootstrapApplication(AppComponent, appConfig); + `, + ); + + addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {importProvidersFrom} from '@angular/core';`); + assertContains(content, `import {FooModule} from '@foo/bar';`); + assertContains( + content, + `const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}, importProvidersFrom(FooModule)] + };`, + ); + }); + + it('should add a module import to a config using mergeApplicationConfig', () => { + host.create( + '/test.ts', + ` + import { mergeApplicationConfig } from '@angular/core'; + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, mergeApplicationConfig(a, b)); + `, + ); + + addModuleImportToStandaloneBootstrap(host, '/test.ts', 'FooModule', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains( + content, + `import {mergeApplicationConfig, importProvidersFrom} from '@angular/core';`, + ); + assertContains(content, `import {FooModule} from '@foo/bar';`); + assertContains( + content, + `bootstrapApplication(AppComponent, mergeApplicationConfig(a, b, { + providers: [importProvidersFrom(FooModule)] + }));`, + ); + }); + }); + + describe('addFunctionalProvidersToStandaloneBootstrap', () => { + it('should be able to add a providers function to a simple `bootstrapApplication` call', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent); + `, + ); + + addFunctionalProvidersToStandaloneBootstrap(host, '/test.ts', 'provideFoo', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {provideFoo} from '@foo/bar';`); + assertContains(content, `bootstrapApplication(AppComponent, {providers: [provideFoo()]});`); + }); + + it('should be able to add a providers function to a `bootstrapApplication` call with an empty options object', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, {}); + `, + ); + + addFunctionalProvidersToStandaloneBootstrap(host, '/test.ts', 'provideFoo', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {provideFoo} from '@foo/bar';`); + assertContains(content, `bootstrapApplication(AppComponent, {providers: [provideFoo()]});`); + }); + + it('should be able to add a providers function to a `bootstrapApplication` call with a pre-existing `providers` array', () => { + host.create( + '/test.ts', + ` + import { enableProdMode } from '@angular/core'; + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + enableProdMode(); + + bootstrapApplication(AppComponent, { + providers: [{provide: 'foo', useValue: 'bar'}] + }); + `, + ); + + addFunctionalProvidersToStandaloneBootstrap(host, '/test.ts', 'provideFoo', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {provideFoo} from '@foo/bar';`); + assertContains( + content, + `bootstrapApplication(AppComponent, { + providers: [{provide: 'foo', useValue: 'bar'}, provideFoo()] + });`, + ); + }); + + it('should throw if there is no `bootstrapModule` call', () => { + host.create( + '/test.ts', + ` + import { AppComponent } from './app.component'; + + console.log(AppComponent); + `, + ); + + expect(() => { + addFunctionalProvidersToStandaloneBootstrap(host, '/test.ts', 'provideFoo', '@foo/bar'); + }).toThrowError(/Could not find bootstrapApplication call in \/test\.ts/); + }); + + it('should add providers to an imported app config', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { appConfig } from './app/app.config'; + + bootstrapApplication(AppComponent, appConfig); + `, + ); + + host.create( + '/app/app.config.ts', + ` + export const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}] + }; + `, + ); + + addFunctionalProvidersToStandaloneBootstrap(host, '/test.ts', 'provideFoo', '@foo/bar'); + + const content = stripWhitespace(host.readText('/app/app.config.ts')); + + assertContains(content, `import {provideFoo} from '@foo/bar';`); + assertContains( + content, + `export const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}, provideFoo()] + };`, + ); + }); + + it('should add providers to an app config imported through an alias', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { appConfig as config } from './app/app.config'; + + bootstrapApplication(AppComponent, config); + `, + ); + + host.create( + '/app/app.config.ts', + ` + export const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}] + }; + `, + ); + + addFunctionalProvidersToStandaloneBootstrap(host, '/test.ts', 'provideFoo', '@foo/bar'); + + const content = stripWhitespace(host.readText('/app/app.config.ts')); + + assertContains(content, `import {provideFoo} from '@foo/bar';`); + assertContains( + content, + `export const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}, provideFoo()] + };`, + ); + }); + + it('should add providers to an app config from a variable in the same file', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}] + }; + + bootstrapApplication(AppComponent, appConfig); + `, + ); + + addFunctionalProvidersToStandaloneBootstrap(host, '/test.ts', 'provideFoo', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {provideFoo} from '@foo/bar';`); + assertContains( + content, + `const appConfig = { + providers: [{provide: 'foo', useValue: 'bar'}, provideFoo()] + };`, + ); + }); + + it('should be able to add a providers function with specific arguments', () => { + host.create( + '/test.ts', + ` + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent); + `, + ); + + addFunctionalProvidersToStandaloneBootstrap(host, '/test.ts', 'provideFoo', '@foo/bar', [ + ts.factory.createNumericLiteral(1), + ts.factory.createStringLiteral('hello', true), + ]); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {provideFoo} from '@foo/bar';`); + assertContains( + content, + `bootstrapApplication(AppComponent, {providers: [provideFoo(1, 'hello')]});`, + ); + }); + + it('should add a providers call to a config using mergeApplicationConfig', () => { + host.create( + '/test.ts', + ` + import { mergeApplicationConfig } from '@angular/core'; + import { bootstrapApplication } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + + bootstrapApplication(AppComponent, mergeApplicationConfig(a, b)); + `, + ); + + addFunctionalProvidersToStandaloneBootstrap(host, '/test.ts', 'provideFoo', '@foo/bar'); + + const content = stripWhitespace(host.readText('/test.ts')); + + assertContains(content, `import {provideFoo} from '@foo/bar';`); + assertContains( + content, + `bootstrapApplication(AppComponent, mergeApplicationConfig(a, b, { + providers: [provideFoo()] + }));`, + ); + }); }); });