From 1c4604026912efd648c8d0db8ad64448eefa61bd Mon Sep 17 00:00:00 2001 From: Emmanuel Azuh Date: Mon, 20 Jun 2016 13:07:08 -0700 Subject: [PATCH] feat: add utility functions for route generation 'route-utils.ts' provides utility functions to be used in generating routes --- addon/ng2/utilities/dynamic-path-parser.js | 1 - addon/ng2/utilities/route-utils.ts | 522 ++++++++++++++++++ tests/acceptance/route-utils.spec.ts | 602 +++++++++++++++++++++ 3 files changed, 1124 insertions(+), 1 deletion(-) create mode 100644 addon/ng2/utilities/route-utils.ts create mode 100644 tests/acceptance/route-utils.spec.ts diff --git a/addon/ng2/utilities/dynamic-path-parser.js b/addon/ng2/utilities/dynamic-path-parser.js index a00300596b70..0313813cafad 100644 --- a/addon/ng2/utilities/dynamic-path-parser.js +++ b/addon/ng2/utilities/dynamic-path-parser.js @@ -56,4 +56,3 @@ module.exports = function dynamicPathParser(project, entityName) { return parsedPath; }; - diff --git a/addon/ng2/utilities/route-utils.ts b/addon/ng2/utilities/route-utils.ts new file mode 100644 index 000000000000..0aea13ca2d56 --- /dev/null +++ b/addon/ng2/utilities/route-utils.ts @@ -0,0 +1,522 @@ +import * as ts from 'typescript'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Change, InsertChange } from './change'; +import * as Promise from 'ember-cli/lib/ext/promise'; +import {findNodes, insertAfterLastOccurrence } from './ast-utils'; + +/** + * Adds imports to mainFile and adds toBootstrap to the array of providers + * in bootstrap, if not present + * @param mainFile main.ts + * @param imports Object { importedClass: ['path/to/import/from', defaultStyleImport?] } + * @param toBootstrap + */ +export function bootstrapItem(mainFile, imports: {[key: string]: [string, boolean?]}, toBootstrap: string ) { + let changes = Object.keys(imports).map(importedClass => { + var defaultStyleImport = imports[importedClass].length === 2 && imports[importedClass][1]; + return insertImport(mainFile, importedClass, imports[importedClass][0], defaultStyleImport); + }); + let rootNode = getRootNode(mainFile); + // get ExpressionStatements from the top level syntaxList of the sourceFile + let bootstrapNodes = rootNode.getChildAt(0).getChildren().filter(node => { + // get bootstrap expressions + return node.kind === ts.SyntaxKind.ExpressionStatement && + node.getChildAt(0).getChildAt(0).text.toLowerCase() === 'bootstrap'; + }); + if (bootstrapNodes.length !== 1) { + throw new Error(`Did not bootstrap provideRouter in ${mainFile}` + + ' because of multiple or no bootstrap calls'); + } + let bootstrapNode = bootstrapNodes[0].getChildAt(0); + let isBootstraped = findNodes(bootstrapNode, ts.SyntaxKind.SyntaxList) // get bootstrapped items + .reduce((a, b) => a.concat(b.getChildren().map(n => n.getText())), []) + .filter(n => n !== ',') + .indexOf(toBootstrap) !== -1; + if (isBootstraped) { + return changes; + } + // if bracket exitst already, add configuration template, + // otherwise, insert into bootstrap parens + var fallBackPos: number, configurePathsTemplate: string, separator: string; + var syntaxListNodes: any; + let bootstrapProviders = bootstrapNode.getChildAt(2).getChildAt(2); // array of providers + + if ( bootstrapProviders ) { + syntaxListNodes = bootstrapProviders.getChildAt(1).getChildren(); + fallBackPos = bootstrapProviders.getChildAt(2).pos; // closeBracketLiteral + separator = syntaxListNodes.length === 0 ? '' : ', '; + configurePathsTemplate = `${separator}${toBootstrap}`; + } else { + fallBackPos = bootstrapNode.getChildAt(3).pos; // closeParenLiteral + syntaxListNodes = bootstrapNode.getChildAt(2).getChildren(); + configurePathsTemplate = `, [ ${toBootstrap} ]`; + } + + changes.push(insertAfterLastOccurrence(syntaxListNodes, configurePathsTemplate, + mainFile, fallBackPos)); + return changes; +} + +/** +* Add Import `import { symbolName } from fileName` if the import doesn't exit +* already. Assumes fileToEdit can be resolved and accessed. +* @param fileToEdit (file we want to add import to) +* @param symbolName (item to import) +* @param fileName (path to the file) +* @param isDefault (if true, import follows style for importing default exports) +* @return Change +*/ + +export function insertImport(fileToEdit: string, symbolName: string, + fileName: string, isDefault = false): Change { + let rootNode = getRootNode(fileToEdit); + let allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + + // get nodes that map to import statements from the file fileName + let relevantImports = allImports.filter(node => { + // StringLiteral of the ImportDeclaration is the import file (fileName in this case). + let importFiles = node.getChildren().filter(child => child.kind === ts.SyntaxKind.StringLiteral) + .map(n => (n).text); + return importFiles.filter(file => file === fileName).length === 1; + }); + + if (relevantImports.length > 0) { + + var importsAsterisk = false; + // imports from import file + let imports: ts.Node[] = []; + relevantImports.forEach(n => { + Array.prototype.push.apply(imports, findNodes(n, ts.SyntaxKind.Identifier)); + if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) { + importsAsterisk = true; + } + }); + + // if imports * from fileName, don't add symbolName + if (importsAsterisk) { + return; + } + + let importTextNodes = imports.filter(n => (n).text === symbolName); + + // insert import if it's not there + if (importTextNodes.length === 0) { + let fallbackPos = findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].pos || + findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].pos; + return insertAfterLastOccurrence(imports, `, ${symbolName}`, fileToEdit, fallbackPos); + } + return; + } + + // no such import declaration exists + let useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral) + .filter(n => n.text === 'use strict'); + let fallbackPos = 0; + if (useStrict.length > 0) { + fallbackPos = useStrict[0].end; + } + let open = isDefault ? '' : '{ '; + let close = isDefault ? '' : ' }'; + // if there are no imports or 'use strict' statement, insert import at beginning of file + let insertAtBeginning = allImports.length === 0 && useStrict.length === 0; + let separator = insertAtBeginning ? '' : ';\n'; + let toInsert = `${separator}import ${open}${symbolName}${close}` + + ` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`; + return insertAfterLastOccurrence(allImports, toInsert, fileToEdit, fallbackPos, ts.SyntaxKind.StringLiteral); +}; + +/** + * Inserts a path to the new route into src/routes.ts if it doesn't exist + * @param routesFile + * @param pathOptions + * @return Change[] + * @throws Error if routesFile has multiple export default or none. + */ +export function addPathToRoutes(routesFile: string, pathOptions: {[key: string]: any}): Change[] { + let route = pathOptions.route.split('/') + .filter(n => n !== '').join('/'); // change say `/about/:id/` to `about/:id` + let isDefault = pathOptions.isDefault ? ', useAsDefault: true' : ''; + let outlet = pathOptions.outlet ? `, outlet: '${pathOptions.outlet}'` : ''; + + // create route path and resolve component import + let positionalRoutes = /\/:[^/]*/g; + let routePath = route.replace(positionalRoutes, ''); + routePath = `./app/${routePath}/${pathOptions.dasherizedName}.component`; + let originalComponent = pathOptions.component; + pathOptions.component = resolveImportName(pathOptions.component, routePath, pathOptions.routesFile); + + var content = `{ path: '${route}', component: ${pathOptions.component}${isDefault}${outlet} }`; + let rootNode = getRootNode(routesFile); + let routesNode = rootNode.getChildAt(0).getChildren().filter(n => { + // get export statement + return n.kind === ts.SyntaxKind.ExportAssignment && + n.getFullText().indexOf('export default') !== -1; + }); + if (routesNode.length !== 1) { + throw new Error('Did not insert path in routes.ts because ' + + `there were multiple or no 'export default' statements`); + } + var pos = routesNode[0].getChildAt(2).getChildAt(0).end; // openBracketLiteral + // all routes in export route array + let routesArray = routesNode[0].getChildAt(2).getChildAt(1) + .getChildren() + .filter(n => n.kind === ts.SyntaxKind.ObjectLiteralExpression); + + if (pathExists(routesArray, route, pathOptions.component)) { + // don't duplicate routes + throw new Error('Route was not added since it is a duplicate'); + } + var isChild = false; + // get parent to insert under + let parent; + if (pathOptions.parent) { + // append '_' to route to find the actual parent (not parent of the parent) + parent = getParent(routesArray, `${pathOptions.parent}/_`); // TODO:find fail safe way to do this + if (!parent) { + throw new Error(`You specified parent '${pathOptions.parent}'' which was not found in routes.ts`); + } + if (route.indexOf(pathOptions.parent) === 0) { + route = route.substring(pathOptions.parent.length); + } + } else { + parent = getParent(routesArray, route); + } + + if (parent) { + let childrenInfo = addChildPath(parent, pathOptions, route); + if (!childrenInfo) { + // path exists already + throw new Error('Route was not added since it is a duplicate'); + } + content = childrenInfo.newContent; + pos = childrenInfo.pos; + isChild = true; + } + + let isFirstElement = routesArray.length === 0; + if (!isChild) { + let separator = isFirstElement ? '\n' : ','; + content = `\n ${content}${separator}`; + } + let changes: Change[] = [new InsertChange(routesFile, pos, content)]; + let component = originalComponent === pathOptions.component ? originalComponent : + `${originalComponent} as ${pathOptions.component}`; + routePath = routePath.replace(/\\/, '/'); // correction in windows + changes.push(insertImport(routesFile, component, routePath)); + return changes; +} + + +/** + * Add more properties to the route object in routes.ts + * @param routesFile routes.ts + * @param route Object {route: [key, value]} + */ +export function addItemsToRouteProperties(routesFile: string, routes: {[key: string]: [string, string]}) { + let rootNode = getRootNode(routesFile); + let routesNode = rootNode.getChildAt(0).getChildren().filter(n => { + // get export statement + return n.kind === ts.SyntaxKind.ExportAssignment && + n.getFullText().indexOf('export default') !== -1; + }); + if (routesNode.length !== 1) { + throw new Error('Did not insert path in routes.ts because ' + + `there were multiple or no 'export default' statements`); + } + let routesArray = routesNode[0].getChildAt(2).getChildAt(1) + .getChildren() + .filter(n => n.kind === ts.SyntaxKind.ObjectLiteralExpression); + let changes: Change[] = Object.keys(routes).reduce((result, route) => { + // let route = routes[guardName][0]; + let itemKey = routes[route][0]; + let itemValue = routes[route][1]; + let currRouteNode = getParent(routesArray, `${route}/_`); + if (!currRouteNode) { + throw new Error(`Could not find '${route}' in routes.ts`); + } + let fallBackPos = findNodes(currRouteNode, ts.SyntaxKind.CloseBraceToken).pop().pos; + let pathPropertiesNodes = currRouteNode.getChildAt(1).getChildren() + .filter(n => n.kind === ts.SyntaxKind.PropertyAssignment); + return result.concat([insertAfterLastOccurrence(pathPropertiesNodes, + `, ${itemKey}: ${itemValue}`, routesFile, fallBackPos)]); + }, []); + return changes; +} + +/** + * Verifies that a component file exports a class of the component + * @param file + * @param componentName + * @return whether file exports componentName + */ +export function confirmComponentExport (file: string, componentName: string): boolean { + const rootNode = getRootNode(file); + let exportNodes = rootNode.getChildAt(0).getChildren().filter(n => { + return n.kind === ts.SyntaxKind.ClassDeclaration && + (n.getChildren().filter(p => p.text === componentName).length !== 0); + }); + return exportNodes.length > 0; +} + +/** + * Ensures there is no collision between import names. If a collision occurs, resolve by adding + * underscore number to the name + * @param importName + * @param importPath path to import component from + * @param fileName (file to add import to) + * @return resolved importName + */ +function resolveImportName (importName: string, importPath: string, fileName: string): string { + const rootNode = getRootNode(fileName); + // get all the import names + let importNodes = rootNode.getChildAt(0).getChildren() + .filter(n => n.kind === ts.SyntaxKind.ImportDeclaration); + // check if imported file is same as current one before updating component name + let importNames = importNodes + .reduce((a, b) => { + let importFrom = findNodes(b, ts.SyntaxKind.StringLiteral); // there's only one + if (importFrom.pop().text !== importPath) { + // importing from different file, add to imported components to inspect + // if only one identifier { FooComponent }, if two { FooComponent as FooComponent_1 } + // choose last element of identifier array in both cases + return a.concat([findNodes(b, ts.SyntaxKind.Identifier).pop()]); + } + return a; + }, []) + .map(n => n.text); + + const index = importNames.indexOf(importName); + if (index === -1) { + return importName; + } + const baseName = importNames[index].split('_')[0]; + var newName = baseName; + var resolutionNumber = 1; + while (importNames.indexOf(newName) !== -1) { + newName = `${baseName}_${resolutionNumber}`; + resolutionNumber++; + } + return newName; +} + +/** + * Resolve a path to a component file. If the path begins with path.sep, it is treated to be + * absolute from the app/ directory. Otherwise, it is relative to currDir + * @param projectRoot + * @param currentDir + * @param filePath componentName or path to componentName + * @return component file name + * @throw Error if component file referenced by path is not found + */ +export function resolveComponentPath(projectRoot: string, currentDir: string, filePath: string) { + + let parsedPath = path.parse(filePath); + let componentName = parsedPath.base.split('.')[0]; + let componentDir = path.parse(parsedPath.dir).base; + + // correction for a case where path is /**/componentName/componentName(.component.ts) + if ( componentName === componentDir) { + filePath = parsedPath.dir; + } + if (parsedPath.dir === '') { + // only component file name is given + filePath = componentName; + } + var directory = filePath[0] === path.sep ? + path.resolve(path.join(projectRoot, 'src', 'app', filePath)) : path.resolve(currentDir, filePath); + + if (!fs.existsSync(directory)) { + throw new Error(`path '${filePath}' must be relative to current directory` + + ` or absolute from project root`); + } + if (directory.indexOf('src' + path.sep + 'app') === -1) { + throw new Error('Route must be within app'); + } + let componentFile = path.join(directory, `${componentName}.component.ts`); + if (!fs.existsSync(componentFile)) { + throw new Error(`could not find component file referenced by ${filePath}`); + } + return componentFile; +} + +/** + * Sort changes in decreasing order and apply them. + * @param changes + * @return Promise + */ +export function applyChanges(changes: Change[]): Promise { + return changes + .filter(change => !!change) + .sort((curr, next) => next.pos - curr.pos) + .reduce((newChange, change) => newChange.then(() => change.apply()), Promise.resolve()); +} +/** + * Helper for addPathToRoutes. Adds child array to the appropriate position in the routes.ts file + * @return Object (pos, newContent) + */ +function addChildPath (parentObject: ts.Node, pathOptions: {[key: string]: any}, route: string) { + if (!parentObject) { + return; + } + var pos: number; + var newContent: string; + + // get object with 'children' property + let childrenNode = parentObject.getChildAt(1).getChildren() + .filter(n => n.kind === ts.SyntaxKind.PropertyAssignment + && n.getChildAt(0).text === 'children'); + // find number of spaces to pad nested paths + let nestingLevel = 1; // for indenting route object in the `children` array + let n = parentObject; + while (n.parent) { + if (n.kind === ts.SyntaxKind.ObjectLiteralExpression + || n.kind === ts.SyntaxKind.ArrayLiteralExpression) { + nestingLevel ++; + } + n = n.parent; + } + + // strip parent route + let parentRoute = parentObject.getChildAt(1).getChildAt(0).getChildAt(2).text; + let childRoute = route.substring(route.indexOf(parentRoute) + parentRoute.length + 1); + + let isDefault = pathOptions.isDefault ? ', useAsDefault: true' : ''; + let outlet = pathOptions.outlet ? `, outlet: '${pathOptions.outlet}'` : ''; + let content = `{ path: '${childRoute}', component: ${pathOptions.component}` + + `${isDefault}${outlet} }`; + let spaces = Array(2 * nestingLevel + 1).join(' '); + + if (childrenNode.length !== 0) { + // add to beginning of children array + pos = childrenNode[0].getChildAt(2).getChildAt(1).pos; // open bracket + newContent = `\n${spaces}${content}, `; + } else { + // no children array, add one + pos = parentObject.getChildAt(2).pos; // close brace + newContent = `,\n${spaces.substring(2)}children: [\n${spaces}${content} ` + + `\n${spaces.substring(2)}]\n${spaces.substring(5)}`; + } + return {newContent: newContent, pos: pos}; +} + +/** + * Helper for addPathToRoutes. + * @return parentNode which contains the children array to add a new path to or + * undefined if none or the entire route was matched. + */ +function getParent(routesArray: ts.Node[], route: string, parent?: ts.Node): ts.Node { + if (routesArray.length === 0 && !parent) { + return; // no children array and no parent found + } + if (route.length === 0) { + return; // route has been completely matched + } + var splitRoute = route.split('/'); + // don't treat positional parameters separately + if (splitRoute.length > 1 && splitRoute[1].indexOf(':') !== -1) { + let actualRoute = splitRoute.shift(); + splitRoute[0] = `${actualRoute}/${splitRoute[0]}`; + } + let potentialParents: ts.Node[] = routesArray // route nodes with same path as current route + .filter(n => getValueForKey(n, 'path') === splitRoute[0]); + if (potentialParents.length !== 0) { + splitRoute.shift(); // matched current parent, move on + route = splitRoute.join('/'); + } + // get all children paths + let newRouteArray = getChildrenArray(routesArray); + if (route && parent && potentialParents.length === 0) { + return parent; // final route is not matched. assign parent from here + } + parent = potentialParents.sort((a, b) => a.pos - b.pos).shift(); + return getParent(newRouteArray, route, parent); +} + +/** + * Helper for addPathToRoutes. + * @return whether path with same route and component exists + */ +function pathExists(routesArray: ts.Node[], route: string, component: string, fullRoute?: string): boolean { + if (routesArray.length === 0) { + return false; + } + fullRoute = fullRoute ? fullRoute : route; + var sameRoute = false; + var splitRoute = route.split('/'); + // don't treat positional parameters separately + if (splitRoute.length > 1 && splitRoute[1].indexOf(':') !== -1) { + let actualRoute = splitRoute.shift(); + splitRoute[0] = `${actualRoute}/${splitRoute[0]}`; + } + let repeatedRoutes: ts.Node[] = routesArray.filter(n => { + let currentRoute = getValueForKey(n, 'path'); + let sameComponent = getValueForKey(n, 'component') === component; + + sameRoute = currentRoute === splitRoute[0]; + // Confirm that it's parents are the same + if (sameRoute && sameComponent) { + var path = currentRoute; + let objExp = n.parent; + while (objExp) { + if (objExp.kind === ts.SyntaxKind.ObjectLiteralExpression) { + let currentParentPath = getValueForKey(objExp, 'path'); + path = currentParentPath ? `${currentParentPath}/${path}` : path; + } + objExp = objExp.parent; + } + return path === fullRoute; + } + return false; + }); + + if (sameRoute) { + splitRoute.shift(); // matched current parent, move on + route = splitRoute.join('/'); + } + if (repeatedRoutes.length !== 0) { + return true; // new path will be repeating if inserted. report that path already exists + } + + // all children paths + let newRouteArray = getChildrenArray(routesArray); + return pathExists(newRouteArray, route, component, fullRoute); +} + +/** + * Helper for getParent and pathExists + * @return array with all nodes holding children array under routes + * in routesArray + */ +function getChildrenArray(routesArray: ts.Node[]): ts.Node[] { + return routesArray.reduce((allRoutes, currRoute) => allRoutes.concat( + currRoute.getChildAt(1).getChildren() + .filter(n => n.kind === ts.SyntaxKind.PropertyAssignment + && n.getChildAt(0).text === 'children') + .map(n => n.getChildAt(2).getChildAt(1)) // syntaxList containing chilren paths + .reduce((childrenArray, currChild) => childrenArray.concat(currChild.getChildren() + .filter(p => p.kind === ts.SyntaxKind.ObjectLiteralExpression) + ), []) + ), []); +} + +/** + * Helper method to get the path text or component + * @param objectLiteralNode + * @param key 'path' or 'component' + */ +function getValueForKey(objectLiteralNode: ts.TypeNode.ObjectLiteralExpression, key: string) { + let currentNode = key === 'component' ? objectLiteralNode.getChildAt(1).getChildAt(2) : + objectLiteralNode.getChildAt(1).getChildAt(0); + return currentNode && currentNode.getChildAt(0) + && currentNode.getChildAt(0).text === key && currentNode.getChildAt(2) + && currentNode.getChildAt(2).text; +} + +/** + * Helper method to get AST from file + * @param file + */ +function getRootNode(file: string) { + return ts.createSourceFile(file, fs.readFileSync(file).toString(), ts.ScriptTarget.ES6, true); +} diff --git a/tests/acceptance/route-utils.spec.ts b/tests/acceptance/route-utils.spec.ts new file mode 100644 index 000000000000..6d1a7c48f8dd --- /dev/null +++ b/tests/acceptance/route-utils.spec.ts @@ -0,0 +1,602 @@ +import * as mockFs from 'mock-fs'; +import * as fs from 'fs'; +import { expect } from 'chai'; +import * as nru from '../../addon/ng2/utilities/route-utils'; +import * as ts from 'typescript'; +import * as path from 'path'; +import { InsertChange, RemoveChange } from '../../addon/ng2/utilities/change'; +import * as Promise from 'ember-cli/lib/ext/promise'; +import * as _ from 'lodash'; + +const readFile = Promise.denodeify(fs.readFile); + +describe('route utils', () => { + describe('insertImport', () => { + const sourceFile = 'tmp/tmp.ts'; + beforeEach(() => { + let mockDrive = { + 'tmp': { + 'tmp.ts': '' + } + }; + mockFs(mockDrive); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('inserts as last import if not present', () => { + let content = `'use strict'\n import {foo} from 'bar'\n import * as fz from 'fizz';`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile.apply() + .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply()) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).to.equal(content + `\nimport { Router } from '@angular/router';`); + }); + }); + it('does not insert if present', () => { + let content = `'use strict'\n import {Router} from '@angular/router'`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile.apply() + .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router')) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).to.equal(content); + }); + }); + it('inserts into existing import clause if import file is already cited', () => { + let content = `'use strict'\n import { foo, bar } from 'fizz'`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile.apply() + .then(() => nru.insertImport(sourceFile, 'baz', 'fizz').apply()) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).to.equal(`'use strict'\n import { foo, bar, baz } from 'fizz'`); + }); + }); + it('understands * imports', () => { + let content = `\nimport * as myTest from 'tests' \n`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile.apply() + .then(() => nru.insertImport(sourceFile, 'Test', 'tests')) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).to.equal(content); + }); + }); + it('inserts after use-strict', () => { + let content = `'use strict';\n hello`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile.apply() + .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply()) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).to.equal( + `'use strict';\nimport { Router } from '@angular/router';\n hello`); + }); + }); + it('inserts inserts at beginning of file if no imports exist', () => { + return nru.insertImport(sourceFile, 'Router', '@angular/router').apply() + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).to.equal(`import { Router } from '@angular/router';\n`); + }); + }); + }); + + describe('bootstrapItem', () => { + const mainFile = 'tmp/main.ts'; + const prefix = `import {bootstrap} from '@angular/platform-browser-dynamic'; \n` + + `import { AppComponent } from './app/';\n`; + const routes = {'provideRouter': ['@angular/router'], 'routes': ['./routes', true]}; + const toBootstrap = 'provideRouter(routes)'; + const routerImport = `import routes from './routes';\n` + + `import { provideRouter } from '@angular/router'; \n`; + beforeEach(() => { + let mockDrive = { + 'tmp': { + 'main.ts': `import {bootstrap} from '@angular/platform-browser-dynamic'; \n` + + `import { AppComponent } from './app/'; \n` + + 'bootstrap(AppComponent);' + } + }; + mockFs(mockDrive); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('adds a provideRouter import if not there already', () => { + return nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ provideRouter(routes) ]);'); + }); + }); + it('does not add a provideRouter import if it exits already', () => { + return nru.insertImport(mainFile, 'provideRouter', '@angular/router').apply() + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))); + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).to.equal( + `import routes from './routes'; + import { provideRouter } from '@angular/router'; + bootstrap(AppComponent, [ provideRouter(routes) ]);`); + }); + }); + it('does not duplicate import to route.ts ', () => { + let editedFile = new InsertChange(mainFile, 100, `\nimport routes from './routes';`); + return editedFile + .apply() + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ provideRouter(routes) ]);'); + }); + }); + it('adds provideRouter to bootstrap if absent and no providers array', () => { + return nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap)) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ provideRouter(routes) ]);'); + }); + }); + it('adds provideRouter to bootstrap if absent and empty providers array', () => { + let editFile = new InsertChange(mainFile, 124, ', []'); + return editFile.apply() + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [provideRouter(routes)]);'); + }); + }); + it('adds provideRouter to bootstrap if absent and non-empty providers array', () => { + let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS ]'); + return editedFile.apply() + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);'); + }); + }); + it('does not add provideRouter to bootstrap if present', () => { + let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS, provideRouter(routes) ]'); + return editedFile.apply() + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);'); + }); + }); + it('inserts into the correct array', () => { + let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS, {provide: [BAR]}]'); + return editedFile.apply() + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ HTTP_PROVIDERS, {provide: [BAR]}, provideRouter(routes)]);'); + }); + }); + it('throws an error if there is no or multiple bootstrap expressions', () => { + let editedFile = new InsertChange(mainFile, 126, '\n bootstrap(moreStuff);'); + return editedFile.apply() + .then(() => nru.bootstrapItem(mainFile, routes, toBootstrap)) + .catch(e => + expect(e.message).to.equal('Did not bootstrap provideRouter in' + + ' tmp/main.ts because of multiple or no bootstrap calls') + ); + }); + it('configures correctly if bootstrap or provide router is not at top level', () => { + let editedFile = new InsertChange(mainFile, 126, '\n if(e){bootstrap, provideRouter});'); + return editedFile.apply() + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ provideRouter(routes) ]);\n if(e){bootstrap, provideRouter});'); + }); + }); + }); + + describe('addPathToRoutes', () => { + const routesFile = 'src/routes.ts'; + var options = {dir: 'src/app', appRoot: 'src/app', routesFile: routesFile, + component: 'NewRouteComponent', dasherizedName: 'new-route'}; + const nestedRoutes = `\n { path: 'home', component: HomeComponent, + children: [ + { path: 'about', component: AboutComponent, + children: [ + { path: 'more', component: MoreComponent } + ] + } + ] + }\n`; + beforeEach(() => { + let mockDrive = { + 'src': { + 'routes.ts' : 'export default [];' + } + }; + mockFs(mockDrive); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('adds import to new route component if absent', () => { + return nru.applyChanges(nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options))) + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + expect(content).to.equal( + `import { NewRouteComponent } from './app/new-route/new-route.component'; +export default [\n { path: 'new-route', component: NewRouteComponent }\n];`); + }); + }); + it('throws error if multiple export defaults exist', () => { + let editedFile = new InsertChange(routesFile, 20, 'export default {}'); + return editedFile.apply().then(() => { + return nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options)); + }).catch(e => { + expect(e.message).to.equal('Did not insert path in routes.ts because ' + + `there were multiple or no 'export default' statements`); + }); + }); + it('throws error if no export defaults exists', () => { + let editedFile = new RemoveChange(routesFile, 0, 'export default []'); + return editedFile.apply().then(() => { + return nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options)); + }).catch(e => { + expect(e.message).to.equal('Did not insert path in routes.ts because ' + + `there were multiple or no 'export default' statements`); + }); + }); + it('treats positional params correctly', () => { + let editedFile = new InsertChange(routesFile, 16, + `\n { path: 'home', component: HomeComponent }\n`); + return editedFile.apply().then(() => { + options.dasherizedName = 'about'; + options.component = 'AboutComponent'; + return nru.applyChanges( + nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/:id'}, options))); }) + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + expect(content).to.equal( + `import { AboutComponent } from './app/home/about/about.component';` + + `\nexport default [\n` + + ` { path: 'home', component: HomeComponent,\n` + + ` children: [\n` + + ` { path: 'about/:id', component: AboutComponent } ` + + `\n ]\n }\n];`); + }); + }); + it('inserts under parent, mid', () => { + let editedFile = new InsertChange(routesFile, 16, nestedRoutes); + return editedFile.apply().then(() => { + options.dasherizedName = 'details'; + options.component = 'DetailsComponent'; + return nru.applyChanges( + nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/details'}, options))); }) + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + let expected = `import { DetailsComponent } from './app/home/about/details/details.component'; +export default [ + { path: 'home', component: HomeComponent, + children: [ + { path: 'about', component: AboutComponent, + children: [ + { path: 'details', component: DetailsComponent }, + { path: 'more', component: MoreComponent } + ] + } + ] + }\n];`; + expect(content).to.equal(expected); + }); + }); + it('inserts under parent, deep', () => { + let editedFile = new InsertChange(routesFile, 16, nestedRoutes); + return editedFile.apply().then(() => { + options.dasherizedName = 'sections'; + options.component = 'SectionsComponent'; + return nru.applyChanges( + nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/more/sections'}, options))); }) + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + let expected = `import { SectionsComponent } from './app/home/about/more/sections/sections.component'; +export default [ + { path: 'home', component: HomeComponent, + children: [ + { path: 'about', component: AboutComponent, + children: [ + { path: 'more', component: MoreComponent, + children: [ + { path: 'sections', component: SectionsComponent } + ] + } + ] + } + ] + } +];`; + expect(content).to.equal(expected); + }); + }); + it('works well with multiple routes in a level', () => { + let paths = `\n { path: 'main', component: MainComponent } + { path: 'home', component: HomeComponent, + children: [ + { path: 'about', component: AboutComponent } + ] + }\n`; + let editedFile = new InsertChange(routesFile, 16, paths); + return editedFile.apply().then(() => { + options.dasherizedName = 'about'; + options.component = 'AboutComponent_1'; + return nru.applyChanges( + nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/:id'}, options))); }) + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + expect(content).to.equal(`import { AboutComponent_1 } from './app/home/about/about.component'; +export default [ + { path: 'main', component: MainComponent } + { path: 'home', component: HomeComponent, + children: [ + { path: 'about/:id', component: AboutComponent_1 }, + { path: 'about', component: AboutComponent } + ] + } +];` + ); + }); + }); + it('throws error if repeating child, shallow', () => { + let editedFile = new InsertChange(routesFile, 16, nestedRoutes); + return editedFile.apply().then(() => { + options.dasherizedName = 'home'; + options.component = 'HomeComponent'; + return nru.addPathToRoutes(routesFile, _.merge({route: '/home'}, options)); + }).catch(e => { + expect(e.message).to.equal('Route was not added since it is a duplicate'); + }); + }); + it('throws error if repeating child, mid', () => { + let editedFile = new InsertChange(routesFile, 16, nestedRoutes); + return editedFile.apply().then(() => { + options.dasherizedName = 'about'; + options.component = 'AboutComponent'; + return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/'}, options)); + }).catch(e => { + expect(e.message).to.equal('Route was not added since it is a duplicate'); + }); + }); + it('throws error if repeating child, deep', () => { + let editedFile = new InsertChange(routesFile, 16, nestedRoutes); + return editedFile.apply().then(() => { + options.dasherizedName = 'more'; + options.component = 'MoreComponent'; + return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/more'}, options)); + }).catch(e => { + expect(e.message).to.equal('Route was not added since it is a duplicate'); + }); + }); + it('does not report false repeat', () => { + let editedFile = new InsertChange(routesFile, 16, nestedRoutes); + return editedFile.apply().then(() => { + options.dasherizedName = 'more'; + options.component = 'MoreComponent'; + return nru.applyChanges(nru.addPathToRoutes(routesFile, _.merge({route: 'more'}, options))); + }) + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + let expected = `import { MoreComponent } from './app/more/more.component'; +export default [ + { path: 'more', component: MoreComponent }, + { path: 'home', component: HomeComponent, + children: [ + { path: 'about', component: AboutComponent, + children: [ + { path: 'more', component: MoreComponent } + ] + } + ] + }\n];`; + expect(content).to.equal(expected); + }); + }); + it('does not report false repeat: multiple paths on a level', () => { + + let routes = `\n { path: 'home', component: HomeComponent, + children: [ + { path: 'about', component: AboutComponent, + children: [ + { path: 'more', component: MoreComponent } + ] + } + ] + },\n { path: 'trap-queen', component: TrapQueenComponent}\n`; + + let editedFile = new InsertChange(routesFile, 16, routes); + return editedFile.apply().then(() => { + options.dasherizedName = 'trap-queen'; + options.component = 'TrapQueenComponent'; + return nru.applyChanges( + nru.addPathToRoutes(routesFile, _.merge({route: 'home/trap-queen'}, options))); }) + .then(() => readFile(routesFile, 'utf8') + .then(content => { + let expected = `import { TrapQueenComponent } from './app/home/trap-queen/trap-queen.component'; +export default [ + { path: 'home', component: HomeComponent, + children: [ + { path: 'trap-queen', component: TrapQueenComponent }, + { path: 'about', component: AboutComponent, + children: [ + { path: 'more', component: MoreComponent } + ] + } + ] + },\n { path: 'trap-queen', component: TrapQueenComponent}\n];`; + expect(content).to.equal(expected); + }); + }); + it('resolves imports correctly', () => { + let editedFile = new InsertChange(routesFile, 16, + `\n { path: 'home', component: HomeComponent }\n`); + return editedFile.apply().then(() => { + let editedFile = new InsertChange(routesFile, 0, + `import { HomeComponent } from './app/home/home.component';\n`); + return editedFile.apply(); + }) + .then(() => { + options.dasherizedName = 'home'; + options.component = 'HomeComponent'; + return nru.applyChanges( + nru.addPathToRoutes(routesFile, _.merge({route: 'home/home'}, options))); }) + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + let expected = `import { HomeComponent } from './app/home/home.component'; +import { HomeComponent as HomeComponent_1 } from './app/home/home/home.component'; +export default [ + { path: 'home', component: HomeComponent, + children: [ + { path: 'home', component: HomeComponent_1 } + ] + } +];`; + expect(content).to.equal(expected); + }); + }); + it('throws error if components collide and there is repitition', () => { + let editedFile = new InsertChange(routesFile, 16, +`\n { path: 'about', component: AboutComponent, + children: [ + { path: 'details/:id', component: DetailsComponent_1 }, + { path: 'details', component: DetailsComponent } + ] + }`); + return editedFile.apply().then(() => { + let editedFile = new InsertChange(routesFile, 0, +`import { AboutComponent } from './app/about/about.component'; +import { DetailsComponent } from './app/about/details/details.component'; +import { DetailsComponent as DetailsComponent_1 } from './app/about/description/details.component;\n`); + return editedFile.apply(); + }).then(() => { + options.dasherizedName = 'details'; + options.component = 'DetailsComponent'; + expect(() => nru.addPathToRoutes(routesFile, _.merge({route: 'about/details'}, options))) + .to.throw(Error); + }); + }); + + it('adds guard to parent route: addItemsToRouteProperties', () => { + let path = `\n { path: 'home', component: HomeComponent }\n`; + let editedFile = new InsertChange(routesFile, 16, path); + return editedFile.apply().then(() => { + let toInsert = {'home': ['canActivate', '[ MyGuard ]'] }; + return nru.applyChanges(nru.addItemsToRouteProperties(routesFile, toInsert)); }) + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + expect(content).to.equal( +`export default [ + { path: 'home', component: HomeComponent, canActivate: [ MyGuard ] } +];` + ); + }); + }); + it('adds guard to child route: addItemsToRouteProperties', () => { + let path = `\n { path: 'home', component: HomeComponent }\n`; + let editedFile = new InsertChange(routesFile, 16, path); + return editedFile.apply().then(() => { + options.dasherizedName = 'more'; + options.component = 'MoreComponent'; + return nru.applyChanges( + nru.addPathToRoutes(routesFile, _.merge({route: 'home/more'}, options))); }) + .then(() => { + return nru.applyChanges(nru.addItemsToRouteProperties(routesFile, + { 'home/more': ['canDeactivate', '[ MyGuard ]'] })); }) + .then(() => { + return nru.applyChanges(nru.addItemsToRouteProperties( + routesFile, { 'home/more': ['useAsDefault', 'true'] })); }) + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + expect(content).to.equal( +`import { MoreComponent } from './app/home/more/more.component'; +export default [ + { path: 'home', component: HomeComponent, + children: [ + { path: 'more', component: MoreComponent, canDeactivate: [ MyGuard ], useAsDefault: true } + ] + } +];` + ); + }); + }); + }); + + describe('validators', () => { + const projectRoot = process.cwd(); + const componentFile = path.join(projectRoot, 'src/app/about/about.component.ts'); + beforeEach(() => { + let mockDrive = { + 'src': { + 'app': { + 'about': { + 'about.component.ts' : 'export class AboutComponent { }' + } + } + } + }; + mockFs(mockDrive); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('accepts component name without \'component\' suffix: resolveComponentPath', () => { + let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about'); + expect(fileName).to.equal(componentFile); + }); + it('accepts component name with \'component\' suffix: resolveComponentPath', () => { + let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about.component'); + expect(fileName).to.equal(componentFile); + }); + it('accepts path absolute from project root: resolveComponentPath', () => { + let fileName = nru.resolveComponentPath(projectRoot, '', `${path.sep}about`); + expect(fileName).to.equal(componentFile); + }); + it('accept component with directory name: resolveComponentPath', () => { + let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about/about.component'); + expect(fileName).to.equal(componentFile); + }); + + it('finds component name: confirmComponentExport', () => { + let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent'); + expect(exportExists).to.be.truthy; + }); + it('finds component in the presence of decorators: confirmComponentExport', () => { + let editedFile = new InsertChange(componentFile, 0, '@Component{}\n'); + return editedFile.apply().then(() => { + let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent'); + expect(exportExists).to.be.truthy; + }); + }); + it('report absence of component name: confirmComponentExport', () => { + let editedFile = new RemoveChange(componentFile, 21, 'onent'); + return editedFile.apply().then(() => { + let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent'); + expect(exportExists).to.not.be.truthy; + }); + }); + }); +});