diff --git a/.gitignore b/.gitignore index 0d473fdf8477..656fd0df7ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +dist/ node_modules/ npm-debug.log diff --git a/.travis.yml b/.travis.yml index ef499ff31908..e4b22a24b80a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ env: - DBUS_SESSION_BUS_ADDRESS=/dev/null matrix: - SCRIPT=lint +# - SCRIPT=build - SCRIPT=test # - TARGET=mobile SCRIPT=mobile_test matrix: @@ -21,8 +22,13 @@ matrix: - os: osx node_js: "5" env: SCRIPT=lint -# - os: osx -# env: TARGET=mobile SCRIPT=mobile_test + - node_js: "6" + env: SCRIPT=build + - os: osx + node_js: "5" + env: SCRIPT=build + - os: osx + env: TARGET=mobile SCRIPT=mobile_test before_install: - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew update; fi diff --git a/addon/ng2/tsconfig.json b/addon/ng2/tsconfig.json index 991af3717a27..37f40a514ae4 100644 --- a/addon/ng2/tsconfig.json +++ b/addon/ng2/tsconfig.json @@ -13,7 +13,10 @@ "sourceMap": true, "sourceRoot": "/", "target": "es5", - "lib": ["es6"] + "lib": ["es6"], + "paths": { + "@angular-cli/ast-tools": [ "../../packages/ast-tools/src" ] + } }, "includes": [ "./custom-typings.d.ts" diff --git a/addon/ng2/utilities/ast-utils.ts b/addon/ng2/utilities/ast-utils.ts index 73c877a4f7a4..ab0396bd55eb 100644 --- a/addon/ng2/utilities/ast-utils.ts +++ b/addon/ng2/utilities/ast-utils.ts @@ -1,304 +1,12 @@ -import * as ts from 'typescript'; -import * as fs from 'fs'; -import {Symbols} from '@angular/tsc-wrapped/src/symbols'; -import { - isMetadataImportedSymbolReferenceExpression, - isMetadataModuleReferenceExpression -} from '@angular/tsc-wrapped'; -import {Change, InsertChange, NoopChange, MultiChange} from './change'; -import {insertImport} from './route-utils'; - -import {Observable} from 'rxjs/Observable'; -import {ReplaySubject} from 'rxjs/ReplaySubject'; -import 'rxjs/add/observable/of'; -import 'rxjs/add/operator/do'; -import 'rxjs/add/operator/filter'; -import 'rxjs/add/operator/last'; -import 'rxjs/add/operator/map'; -import 'rxjs/add/operator/mergeMap'; -import 'rxjs/add/operator/toArray'; -import 'rxjs/add/operator/toPromise'; - - -/** -* Get TS source file based on path. -* @param filePath -* @return source file of ts.SourceFile kind -*/ -export function getSource(filePath: string): ts.SourceFile { - return ts.createSourceFile(filePath, fs.readFileSync(filePath).toString(), - ts.ScriptTarget.ES6, true); -} - - -/** - * Get all the nodes from a source, as an observable. - * @param sourceFile The source file object. - * @returns {Observable} An observable of all the nodes in the source. - */ -export function getSourceNodes(sourceFile: ts.SourceFile): Observable { - const subject = new ReplaySubject(); - let nodes: ts.Node[] = [sourceFile]; - - while(nodes.length > 0) { - const node = nodes.shift(); - - if (node) { - subject.next(node); - if (node.getChildCount(sourceFile) >= 0) { - nodes.unshift(...node.getChildren()); - } - } - } - - subject.complete(); - return subject.asObservable(); -} - - -/** - * Find all nodes from the AST in the subtree of node of SyntaxKind kind. - * @param node - * @param kind - * @param max The maximum number of items to return. - * @return all nodes of kind, or [] if none is found -*/ -export function findNodes(node: ts.Node, kind: ts.SyntaxKind, max: number = Infinity): ts.Node[] { - if (!node || max == 0) { - return []; - } - - let arr: ts.Node[] = []; - if (node.kind === kind) { - arr.push(node); - max--; - } - if (max > 0) { - for (const child of node.getChildren()) { - findNodes(child, kind, max).forEach(node => { - if (max > 0) { - arr.push(node); - } - max--; - }); - - if (max <= 0) { - break; - } - } - } - return arr; -} - - -/** - * Helper for sorting nodes. - * @return function to sort nodes in increasing order of position in sourceFile - */ -function nodesByPosition(first: ts.Node, second: ts.Node): number { - return first.pos - second.pos; -} - - -/** - * Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]` - * or after the last of occurence of `syntaxKind` if the last occurence is a sub child - * of ts.SyntaxKind[nodes[i].kind] and save the changes in file. - * - * @param nodes insert after the last occurence of nodes - * @param toInsert string to insert - * @param file file to insert changes into - * @param fallbackPos position to insert if toInsert happens to be the first occurence - * @param syntaxKind the ts.SyntaxKind of the subchildren to insert after - * @return Change instance - * @throw Error if toInsert is first occurence but fall back is not set - */ -export function insertAfterLastOccurrence(nodes: ts.Node[], toInsert: string, - file: string, fallbackPos?: number, syntaxKind?: ts.SyntaxKind): Change { - var lastItem = nodes.sort(nodesByPosition).pop(); - if (syntaxKind) { - lastItem = findNodes(lastItem, syntaxKind).sort(nodesByPosition).pop(); - } - if (!lastItem && fallbackPos == undefined) { - throw new Error(`tried to insert ${toInsert} as first occurence with no fallback position`); - } - let lastItemPosition: number = lastItem ? lastItem.end : fallbackPos; - return new InsertChange(file, lastItemPosition, toInsert); -} - - -export function getContentOfKeyLiteral(source: ts.SourceFile, node: ts.Node): string { - if (node.kind == ts.SyntaxKind.Identifier) { - return (node).text; - } else if (node.kind == ts.SyntaxKind.StringLiteral) { - try { - return JSON.parse(node.getFullText(source)) - } catch (e) { - return null; - } - } else { - return null; - } -} - - -export function getDecoratorMetadata(source: ts.SourceFile, identifier: string, - module: string): Observable { - const symbols = new Symbols(source); - - return getSourceNodes(source) - .filter(node => { - return node.kind == ts.SyntaxKind.Decorator - && (node).expression.kind == ts.SyntaxKind.CallExpression; - }) - .map(node => (node).expression) - .filter(expr => { - if (expr.expression.kind == ts.SyntaxKind.Identifier) { - const id = expr.expression; - const metaData = symbols.resolve(id.getFullText(source)); - if (isMetadataImportedSymbolReferenceExpression(metaData)) { - return metaData.name == identifier && metaData.module == module; - } - } else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) { - // This covers foo.NgModule when importing * as foo. - const paExpr = expr.expression; - // If the left expression is not an identifier, just give up at that point. - if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) { - return false; - } - - const id = paExpr.name; - const moduleId = paExpr.expression; - const moduleMetaData = symbols.resolve(moduleId.getFullText(source)); - if (isMetadataModuleReferenceExpression(moduleMetaData)) { - return moduleMetaData.module == module && id.getFullText(source) == identifier; - } - } - return false; - }) - .filter(expr => expr.arguments[0] - && expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression) - .map(expr => expr.arguments[0]); -} - - -function _addSymbolToNgModuleMetadata(ngModulePath: string, metadataField: string, - symbolName: string, importPath: string) { - const source: ts.SourceFile = getSource(ngModulePath); - let metadata = getDecoratorMetadata(source, 'NgModule', '@angular/core'); - - // Find the decorator declaration. - return metadata - .toPromise() - .then((node: ts.ObjectLiteralExpression) => { - if (!node) { - return null; - } - - // Get all the children property assignment of object literals. - return node.properties - .filter(prop => prop.kind == ts.SyntaxKind.PropertyAssignment) - // Filter out every fields that's not "metadataField". Also handles string literals - // (but not expressions). - .filter(prop => { - switch (prop.name.kind) { - case ts.SyntaxKind.Identifier: - return prop.name.getText(source) == metadataField; - case ts.SyntaxKind.StringLiteral: - return prop.name.text == metadataField; - } - - return false; - }); - }) - // Get the last node of the array literal. - .then(matchingProperties => { - if (!matchingProperties) { - return; - } - if (matchingProperties.length == 0) { - return metadata - .toPromise(); - } - - const assignment = matchingProperties[0]; - - // If it's not an array, nothing we can do really. - if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { - return Observable.empty(); - } - - const arrLiteral = assignment.initializer; - if (arrLiteral.elements.length == 0) { - // Forward the property. - return arrLiteral; - } - return arrLiteral.elements; - }) - .then((node: ts.Node) => { - if (!node) { - console.log('No app module found. Please add your new class to your component.'); - return new NoopChange(); - } - if (Array.isArray(node)) { - node = node[node.length - 1]; - } - - let toInsert; - let position = node.getEnd(); - if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) { - // We haven't found the field in the metadata declaration. Insert a new - // field. - let expr = node; - if (expr.properties.length == 0) { - position = expr.getEnd() - 1; - toInsert = ` ${metadataField}: [${symbolName}]\n`; - } else { - node = expr.properties[expr.properties.length - 1]; - position = node.getEnd(); - // Get the indentation of the last element, if any. - const text = node.getFullText(source); - if (text.startsWith('\n')) { - toInsert = `,${text.match(/^\n(\r?)\s+/)[0]}${metadataField}: [${symbolName}]`; - } else { - toInsert = `, ${metadataField}: [${symbolName}]`; - } - } - } else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) { - // We found the field but it's empty. Insert it just before the `]`. - position--; - toInsert = `${symbolName}`; - } else { - // Get the indentation of the last element, if any. - const text = node.getFullText(source); - if (text.startsWith('\n')) { - toInsert = `,${text.match(/^\n(\r?)\s+/)[0]}${symbolName}`; - } else { - toInsert = `, ${symbolName}`; - } - } - - const insert = new InsertChange(ngModulePath, position, toInsert); - const importInsert: Change = insertImport(ngModulePath, symbolName, importPath); - return new MultiChange([insert, importInsert]); - }); -} - -/** -* Custom function to insert a declaration (component, pipe, directive) -* into NgModule declarations. It also imports the component. -*/ -export function addComponentToModule(modulePath: string, classifiedName: string, - importPath: string): Promise { - - return _addSymbolToNgModuleMetadata(modulePath, 'declarations', classifiedName, importPath); -} - -/** - * Custom function to insert a provider into NgModule. It also imports it. - */ -export function addProviderToModule(modulePath: string, classifiedName: string, - importPath: string): Promise { - return _addSymbolToNgModuleMetadata(modulePath, 'providers', classifiedName, importPath); -} - +// In order to keep refactoring low, simply export from ast-tools. +// TODO: move all dependencies of this file to ast-tools directly. +export { + getSource, + getSourceNodes, + findNodes, + insertAfterLastOccurrence, + getContentOfKeyLiteral, + getDecoratorMetadata, + addComponentToModule, + addProviderToModule +} from '@angular-cli/ast-tools'; diff --git a/addon/ng2/utilities/change.ts b/addon/ng2/utilities/change.ts index 50ae75b40f6b..af47c4fd021a 100644 --- a/addon/ng2/utilities/change.ts +++ b/addon/ng2/utilities/change.ts @@ -1,166 +1,8 @@ -'use strict'; - -import * as Promise from 'ember-cli/lib/ext/promise'; -import fs = require('fs'); - -const readFile = Promise.denodeify(fs.readFile); -const writeFile = Promise.denodeify(fs.writeFile); - -export interface Change { - - apply(): Promise; - - // The file this change should be applied to. Some changes might not apply to - // a file (maybe the config). - path: string | null; - - // The order this change should be applied. Normally the position inside the file. - // Changes are applied from the bottom of a file to the top. - order: number; - - // The description of this change. This will be outputted in a dry or verbose run. - description: string; -} - - -/** - * An operation that does nothing. - */ -export class NoopChange implements Change { - get description() { return 'No operation.'; } - get order() { return Infinity; } - get path() { return null; } - apply() { return Promise.resolve(); } -} - -/** - * An operation that mixes two or more changes, and merge them (in order). - * Can only apply to a single file. Use a ChangeManager to apply changes to multiple - * files. - */ -export class MultiChange implements Change { - private _path: string; - private _changes: Change[]; - - constructor(...changes: Array) { - this._changes = []; - [].concat(...changes).forEach(change => this.appendChange(change)); - } - - appendChange(change: Change) { - // Validate that the path is the same for everyone of those. - if (this._path === undefined) { - this._path = change.path; - } else if (change.path !== this._path) { - throw new Error('Cannot apply a change to a different path.'); - } - this._changes.push(change); - } - - get description() { - return `Changes:\n ${this._changes.map(x => x.description).join('\n ')}`; - } - // Always apply as early as the highest change. - get order() { return Math.max(...this._changes); } - get path() { return this._path; } - - apply() { - return this._changes - .sort((a: Change, b: Change) => b.order - a.order) - .reduce((promise, change) => { - return promise.then(() => change.apply()) - }, Promise.resolve()); - } -} - - -/** - * Will add text to the source code. - */ -export class InsertChange implements Change { - - const order: number; - const description: string; - - constructor( - public path: string, - private pos: number, - private toAdd: string, - ) { - if (pos < 0) { - throw new Error('Negative positions are invalid'); - } - this.description = `Inserted ${toAdd} into position ${pos} of ${path}`; - this.order = pos; - } - - /** - * This method does not insert spaces if there is none in the original string. - */ - apply(): Promise { - return readFile(this.path, 'utf8').then(content => { - let prefix = content.substring(0, this.pos); - let suffix = content.substring(this.pos); - return writeFile(this.path, `${prefix}${this.toAdd}${suffix}`); - }); - } -} - -/** - * Will remove text from the source code. - */ -export class RemoveChange implements Change { - - const order: number; - const description: string; - - constructor( - public path: string, - private pos: number, - private toRemove: string) { - if (pos < 0) { - throw new Error('Negative positions are invalid'); - } - this.description = `Removed ${toRemove} into position ${pos} of ${path}`; - this.order = pos; - } - - apply(): Promise { - return readFile(this.path, 'utf8').then(content => { - let prefix = content.substring(0, this.pos); - let suffix = content.substring(this.pos + this.toRemove.length); - // TODO: throw error if toRemove doesn't match removed string. - return writeFile(this.path, `${prefix}${suffix}`); - }); - } -} - -/** - * Will replace text from the source code. - */ -export class ReplaceChange implements Change { - - const order: number; - const description: string; - - constructor( - public path: string, - private pos: number, - private oldText: string, - private newText: string) { - if (pos < 0) { - throw new Error('Negative positions are invalid'); - } - this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`; - this.order = pos; - } - - apply(): Promise { - return readFile(this.path, 'utf8').then(content => { - let prefix = content.substring(0, this.pos); - let suffix = content.substring(this.pos + this.oldText.length); - // TODO: throw error if oldText doesn't match removed string. - return writeFile(this.path, `${prefix}${this.newText}${suffix}`); - }); - } -} +export { + Change, + NoopChange, + MultiChange, + InsertChange, + RemoveChange, + ReplaceChange +} from '@angular-cli/ast-tools'; diff --git a/addon/ng2/utilities/route-utils.ts b/addon/ng2/utilities/route-utils.ts index 34716bfefa97..cc08ad7dd6f1 100644 --- a/addon/ng2/utilities/route-utils.ts +++ b/addon/ng2/utilities/route-utils.ts @@ -1,522 +1,11 @@ -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}/_`); - 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); -} +// In order to keep refactoring low, simply export from ast-tools. +// TODO: move all dependencies of this file to ast-tools directly. +export { + bootstrapItem, + insertImport, + addPathToRoutes, + addItemsToRouteProperties, + confirmComponentExport, + resolveComponentPath, + applyChanges +} from '@angular-cli/ast-tools'; diff --git a/bin/ng b/bin/ng index 34f0bd650b6f..af0abf76f999 100755 --- a/bin/ng +++ b/bin/ng @@ -9,6 +9,9 @@ const exit = require('exit'); const packageJson = require('../package.json'); const Leek = require('leek'); +require('../lib/bootstrap-local'); + + resolve('angular-cli', { basedir: process.cwd() }, function (error, projectLocalCli) { var cli; diff --git a/lib/bootstrap-local.js b/lib/bootstrap-local.js new file mode 100644 index 000000000000..773cca8930ce --- /dev/null +++ b/lib/bootstrap-local.js @@ -0,0 +1,54 @@ +/* eslint-disable no-console */ +'use strict'; + +const fs = require('fs'); +const ts = require('typescript'); + + +const oldRequireTs = require.extensions['.ts']; +require.extensions['.ts'] = function(m, filename) { + // If we're in node module, either call the old hook or simply compile the + // file without transpilation. We do not touch node_modules/**. + // We do touch `angular-cli` files anywhere though. + if (!filename.match(/angular-cli/) && filename.match(/node_modules/)) { + if (oldRequireTs) { + return oldRequireTs(m, filename); + } + return m._compile(fs.readFileSync(filename), filename); + } + + // Node requires all require hooks to be sync. + const source = fs.readFileSync(filename).toString(); + + try { + const result = ts.transpile(source, { + target: ts.ScriptTarget.ES5, + module: ts.ModuleKind.CommonJs + }); + + // Send it to node to execute. + return m._compile(result, filename); + } catch (err) { + console.error('Error while running script "' + filename + '":'); + console.error(err.stack); + throw err; + } +}; + + + +// If we're running locally, meaning npm linked. This is basically "developer mode". +if (!__dirname.match(/\/node_modules\//)) { + const packages = require('./packages'); + + // We mock the module loader so that we can fake our packages when running locally. + const Module = require('module'); + const oldLoad = Module._load; + Module._load = function (request, parent) { + if (request in packages) { + return oldLoad.call(this, packages[request].main, parent); + } else { + return oldLoad.apply(this, arguments); + } + }; +} diff --git a/lib/cli/index.js b/lib/cli/index.js index ca8598fc2ee5..ed6dde37ec58 100644 --- a/lib/cli/index.js +++ b/lib/cli/index.js @@ -1,40 +1,6 @@ /*eslint-disable no-console */ // This file hooks up on require calls to transpile TypeScript. -const fs = require('fs'); -const ts = require('typescript'); -const old = require.extensions['.ts']; - -require.extensions['.ts'] = function(m, filename) { - // If we're in node module, either call the old hook or simply compile the - // file without transpilation. We do not touch node_modules/**. - // We do touch `angular-cli` files anywhere though. - if (!filename.match(/angular-cli/) && filename.match(/node_modules/)) { - if (old) { - return old(m, filename); - } - return m._compile(fs.readFileSync(filename), filename); - } - - // Node requires all require hooks to be sync. - const source = fs.readFileSync(filename).toString(); - - try { - const result = ts.transpile(source, { - target: ts.ScriptTarget.ES5, - module: ts.ModuleKind.CommonJs - }); - - // Send it to node to execute. - return m._compile(result, filename); - } catch (err) { - console.error('Error while running script "' + filename + '":'); - console.error(err.stack); - throw err; - } -}; - - const cli = require('ember-cli/lib/cli'); const path = require('path'); diff --git a/lib/packages.js b/lib/packages.js new file mode 100644 index 000000000000..18412efbec14 --- /dev/null +++ b/lib/packages.js @@ -0,0 +1,19 @@ +const fs = require('fs'); +const path = require('path'); + +const packageRoot = path.join(__dirname, '../packages'); + +// All the supported packages. Go through the packages directory and create a map of +// name => fullPath. +const packages = fs.readdirSync(packageRoot) + .map(pkgName => ({ name: pkgName, root: path.join(packageRoot, pkgName) })) + .filter(pkg => fs.statSync(pkg.root).isDirectory()) + .reduce((packages, pkg) => { + packages[`@angular-cli/${pkg.name}`] = { + root: pkg.root, + main: path.resolve(pkg.root, 'src/index.ts') + }; + return packages; + }, {}); + +module.exports = packages; \ No newline at end of file diff --git a/package.json b/package.json index d984c8dd43c5..58105b89bdbb 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,12 @@ }, "keywords": [], "scripts": { - "test": "node tests/runner", + "build": "for PKG in packages/*; do echo Building $PKG...; tsc -P $PKG; done", + "test": "npm run test:packages && npm run test:cli", "mobile_test": "mocha tests/e2e/e2e_workflow.spec.js", + "test:cli": "node tests/runner", "test:inspect": "node --inspect --debug-brk tests/runner", + "test:packages": "node scripts/run-packages-spec.js", "lint": "eslint .", "build-config-interface": "dtsgen lib/config/schema.json --out lib/config/schema.d.ts" }, @@ -35,9 +38,6 @@ "dependencies": { "@angular/core": "^2.0.0-rc.5", "@angular/tsc-wrapped": "^0.2.2", - "@types/lodash": "^4.0.25-alpha", - "@types/rimraf": "0.0.25-alpha", - "@types/webpack": "^1.12.22-alpha", "angular2-template-loader": "^0.5.0", "awesome-typescript-loader": "^2.2.1", "chalk": "^1.1.3", @@ -90,7 +90,6 @@ "stylus-loader": "^2.1.0", "symlink-or-copy": "^1.0.3", "ts-loader": "^0.8.2", - "tslint": "^3.11.0", "tslint-loader": "^2.1.4", "typedoc": "^0.4.2", "typescript": "^2.0.0", @@ -106,13 +105,21 @@ ] }, "devDependencies": { + "@types/denodeify": "^1.2.29", + "@types/jasmine": "^2.2.32", + "@types/lodash": "^4.0.25-alpha", + "@types/mock-fs": "3.6.28", "@types/node": "^6.0.36", + "@types/rimraf": "0.0.25-alpha", + "@types/webpack": "^1.12.22-alpha", "chai": "^3.5.0", "conventional-changelog": "^1.1.0", "denodeify": "^1.2.1", "dtsgenerator": "^0.7.1", "eslint": "^2.8.0", "exists-sync": "0.0.3", + "jasmine": "^2.4.1", + "jasmine-spec-reporter": "^2.7.0", "minimatch": "^3.0.0", "mocha": "^2.4.5", "mock-fs": "3.10.0", @@ -121,7 +128,7 @@ "sinon": "^1.17.3", "through": "^2.3.8", "tree-kill": "^1.0.0", - "tslint": "^3.8.1", + "tslint": "^3.11.0", "walk-sync": "^0.2.6" } } diff --git a/packages/ast-tools/package.json b/packages/ast-tools/package.json new file mode 100644 index 000000000000..10da67d0d66f --- /dev/null +++ b/packages/ast-tools/package.json @@ -0,0 +1,27 @@ +{ + "name": "angular-cli", + "version": "1.0.0-beta.11-webpack.2", + "description": "CLI tool for Angular", + "main": "./index.js", + "keywords": [ + "angular", + "cli", + "ast", + "tool" + ], + "repository": { + "type": "git", + "url": "https://github.com/angular/angular-cli.git" + }, + "author": "angular", + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/angular-cli/issues" + }, + "homepage": "https://github.com/angular/angular-cli", + "dependencies": { + "rxjs": "^5.0.0-beta.11", + "denodeify": "^1.2.1", + "typescript": "^2.0.0" + } +} diff --git a/tests/acceptance/ast-utils.spec.ts b/packages/ast-tools/src/ast-utils.spec.ts similarity index 58% rename from tests/acceptance/ast-utils.spec.ts rename to packages/ast-tools/src/ast-utils.spec.ts index e98d929b3dfb..7161b03f166b 100644 --- a/tests/acceptance/ast-utils.spec.ts +++ b/packages/ast-tools/src/ast-utils.spec.ts @@ -1,16 +1,15 @@ -import * as mockFs from 'mock-fs'; -import { expect } from 'chai'; -import * as ts from 'typescript'; -import * as fs from 'fs'; -import { InsertChange, RemoveChange } from '../../addon/ng2/utilities/change'; -import * as Promise from 'ember-cli/lib/ext/promise'; -import { - findNodes, - insertAfterLastOccurrence, - addComponentToModule -} from '../../addon/ng2/utilities/ast-utils'; +import denodeify = require('denodeify'); +import mockFs = require('mock-fs'); +import ts = require('typescript'); +import fs = require('fs'); + +import {InsertChange, RemoveChange} from './change'; +import {insertAfterLastOccurrence, addComponentToModule} from './ast-utils'; +import {findNodes} from './node'; +import {it} from './spec-utils'; + +const readFile = denodeify(fs.readFile); -const readFile = Promise.denodeify(fs.readFile); describe('ast-utils: findNodes', () => { const sourceFile = 'tmp/tmp.ts'; @@ -19,7 +18,7 @@ describe('ast-utils: findNodes', () => { let mockDrive = { 'tmp': { 'tmp.ts': `import * as myTest from 'tests' \n` + - 'hello.' + 'hello.' } }; mockFs(mockDrive); @@ -32,42 +31,42 @@ describe('ast-utils: findNodes', () => { it('finds no imports', () => { let editedFile = new RemoveChange(sourceFile, 0, `import * as myTest from 'tests' \n`); return editedFile - .apply() - .then(() => { - let rootNode = getRootNode(sourceFile); - let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - expect(nodes).to.be.empty; - }); + .apply() + .then(() => { + let rootNode = getRootNode(sourceFile); + let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + expect(nodes).toEqual([]); + }); }); it('finds one import', () => { let rootNode = getRootNode(sourceFile); let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - expect(nodes.length).to.equal(1); + expect(nodes.length).toEqual(1); }); it('finds two imports from inline declarations', () => { // remove new line and add an inline import let editedFile = new RemoveChange(sourceFile, 32, '\n'); return editedFile - .apply() - .then(() => { - let insert = new InsertChange(sourceFile, 32, `import {Routes} from '@angular/routes'`); - return insert.apply(); - }) - .then(() => { - let rootNode = getRootNode(sourceFile); - let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - expect(nodes.length).to.equal(2); - }); + .apply() + .then(() => { + let insert = new InsertChange(sourceFile, 32, `import {Routes} from '@angular/routes'`); + return insert.apply(); + }) + .then(() => { + let rootNode = getRootNode(sourceFile); + let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + expect(nodes.length).toEqual(2); + }); }); it('finds two imports from new line separated declarations', () => { let editedFile = new InsertChange(sourceFile, 33, `import {Routes} from '@angular/routes'`); return editedFile - .apply() - .then(() => { - let rootNode = getRootNode(sourceFile); - let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); - expect(nodes.length).to.equal(2); - }); + .apply() + .then(() => { + let rootNode = getRootNode(sourceFile); + let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + expect(nodes.length).toEqual(2); + }); }); }); @@ -89,86 +88,93 @@ describe('ast-utils: insertAfterLastOccurrence', () => { it('inserts at beginning of file', () => { let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); return insertAfterLastOccurrence(imports, `\nimport { Router } from '@angular/router';`, - sourceFile, 0) - .apply() - .then(() => { - return readFile(sourceFile, 'utf8'); - }).then((content) => { - let expected = '\nimport { Router } from \'@angular/router\';'; - expect(content).to.equal(expected); - }); + sourceFile, 0) + .apply() + .then(() => { + return readFile(sourceFile, 'utf8'); + }).then((content) => { + let expected = '\nimport { Router } from \'@angular/router\';'; + expect(content).toEqual(expected); + }); }); it('throws an error if first occurence with no fallback position', () => { let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); expect(() => insertAfterLastOccurrence(imports, `import { Router } from '@angular/router';`, - sourceFile)).to.throw(Error); + sourceFile)).toThrowError(); }); it('inserts after last import', () => { let content = `import { foo, bar } from 'fizz';`; let editedFile = new InsertChange(sourceFile, 0, content); return editedFile - .apply() - .then(() => { - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - return insertAfterLastOccurrence(imports, ', baz', sourceFile, - 0, ts.SyntaxKind.Identifier) - .apply(); - }).then(() => { - return readFile(sourceFile, 'utf8'); - }).then(newContent => expect(newContent).to.equal(`import { foo, bar, baz } from 'fizz';`)); + .apply() + .then(() => { + let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); + return insertAfterLastOccurrence(imports, ', baz', sourceFile, + 0, ts.SyntaxKind.Identifier) + .apply(); + }) + .then(() => { + return readFile(sourceFile, 'utf8'); + }) + .then(newContent => expect(newContent).toEqual(`import { foo, bar, baz } from 'fizz';`)); }); it('inserts after last import declaration', () => { let content = `import * from 'foo' \n import { bar } from 'baz'`; let editedFile = new InsertChange(sourceFile, 0, content); return editedFile - .apply() - .then(() => { - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - return insertAfterLastOccurrence(imports, `\nimport Router from '@angular/router'`, - sourceFile) - .apply(); - }).then(() => { - return readFile(sourceFile, 'utf8'); - }).then(newContent => { - let expected = `import * from 'foo' \n import { bar } from 'baz'` + - `\nimport Router from '@angular/router'`; - expect(newContent).to.equal(expected); - }); + .apply() + .then(() => { + let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); + return insertAfterLastOccurrence(imports, `\nimport Router from '@angular/router'`, + sourceFile) + .apply(); + }) + .then(() => { + return readFile(sourceFile, 'utf8'); + }) + .then(newContent => { + let expected = `import * from 'foo' \n import { bar } from 'baz'` + + `\nimport Router from '@angular/router'`; + expect(newContent).toEqual(expected); + }); }); it('inserts correctly if no imports', () => { let content = `import {} from 'foo'`; let editedFile = new InsertChange(sourceFile, 0, content); return editedFile - .apply() - .then(() => { - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - return insertAfterLastOccurrence(imports, ', bar', sourceFile, undefined, - ts.SyntaxKind.Identifier) - .apply(); - }).catch(() => { - return readFile(sourceFile, 'utf8'); - }) - .then(newContent => { - expect(newContent).to.equal(content); - // use a fallback position for safety - let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); - let pos = findNodes(imports.sort((a, b) => a.pos - b.pos).pop(), - ts.SyntaxKind.CloseBraceToken).pop().pos; - return insertAfterLastOccurrence(imports, ' bar ', - sourceFile, pos, ts.SyntaxKind.Identifier) - .apply(); - }).then(() => { - return readFile(sourceFile, 'utf8'); - }).then(newContent => { - expect(newContent).to.equal(`import { bar } from 'foo'`); - }); + .apply() + .then(() => { + let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); + return insertAfterLastOccurrence(imports, ', bar', sourceFile, undefined, + ts.SyntaxKind.Identifier) + .apply(); + }) + .catch(() => { + return readFile(sourceFile, 'utf8'); + }) + .then(newContent => { + expect(newContent).toEqual(content); + // use a fallback position for safety + let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); + let pos = findNodes(imports.sort((a, b) => a.pos - b.pos).pop(), + ts.SyntaxKind.CloseBraceToken).pop().pos; + return insertAfterLastOccurrence(imports, ' bar ', + sourceFile, pos, ts.SyntaxKind.Identifier) + .apply(); + }) + .then(() => { + return readFile(sourceFile, 'utf8'); + }) + .then(newContent => { + expect(newContent).toEqual(`import { bar } from 'foo'`); + }); }); }); describe('addComponentToModule', () => { beforeEach(() => { - mockFs( { + mockFs({ '1.ts': ` import {NgModule} from '@angular/core'; @@ -208,7 +214,7 @@ class Module {}` .then(change => change.apply()) .then(() => readFile('1.ts', 'utf-8')) .then(content => { - expect(content).to.equal( + expect(content).toEqual( '\n' + 'import {NgModule} from \'@angular/core\';\n' + 'import { MyClass } from \'MyImportPath\';\n' + @@ -218,7 +224,7 @@ class Module {}` '})\n' + 'class Module {}' ); - }) + }); }); it('works with array with declarations', () => { @@ -226,7 +232,7 @@ class Module {}` .then(change => change.apply()) .then(() => readFile('2.ts', 'utf-8')) .then(content => { - expect(content).to.equal( + expect(content).toEqual( '\n' + 'import {NgModule} from \'@angular/core\';\n' + 'import { MyClass } from \'MyImportPath\';\n' + @@ -239,7 +245,7 @@ class Module {}` '})\n' + 'class Module {}' ); - }) + }); }); it('works without any declarations', () => { @@ -247,7 +253,7 @@ class Module {}` .then(change => change.apply()) .then(() => readFile('3.ts', 'utf-8')) .then(content => { - expect(content).to.equal( + expect(content).toEqual( '\n' + 'import {NgModule} from \'@angular/core\';\n' + 'import { MyClass } from \'MyImportPath\';\n' + @@ -257,7 +263,7 @@ class Module {}` '})\n' + 'class Module {}' ); - }) + }); }); it('works without a declaration field', () => { @@ -265,7 +271,7 @@ class Module {}` .then(change => change.apply()) .then(() => readFile('4.ts', 'utf-8')) .then(content => { - expect(content).to.equal( + expect(content).toEqual( '\n' + 'import {NgModule} from \'@angular/core\';\n' + 'import { MyClass } from \'MyImportPath\';\n' + @@ -277,11 +283,11 @@ class Module {}` '})\n' + 'class Module {}' ); - }) + }); }); }); - /** +/** * Gets node of kind kind from sourceFile */ function getNodesOfKind(kind: ts.SyntaxKind, sourceFile: string) { @@ -290,5 +296,5 @@ function getNodesOfKind(kind: ts.SyntaxKind, sourceFile: string) { function getRootNode(sourceFile: string) { return ts.createSourceFile(sourceFile, fs.readFileSync(sourceFile).toString(), - ts.ScriptTarget.ES6, true); + ts.ScriptTarget.ES6, true); } diff --git a/packages/ast-tools/src/ast-utils.ts b/packages/ast-tools/src/ast-utils.ts new file mode 100644 index 000000000000..89e022fb85c2 --- /dev/null +++ b/packages/ast-tools/src/ast-utils.ts @@ -0,0 +1,271 @@ +import * as ts from 'typescript'; +import * as fs from 'fs'; +import {Symbols} from '@angular/tsc-wrapped/src/symbols'; +import { + isMetadataImportedSymbolReferenceExpression, + isMetadataModuleReferenceExpression +} from '@angular/tsc-wrapped'; +import {Change, InsertChange, NoopChange, MultiChange} from './change'; +import {findNodes} from './node'; +import {insertImport} from './route-utils'; + +import {Observable} from 'rxjs/Observable'; +import {ReplaySubject} from 'rxjs/ReplaySubject'; +import 'rxjs/add/observable/empty'; +import 'rxjs/add/observable/of'; +import 'rxjs/add/operator/do'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/last'; +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/mergeMap'; +import 'rxjs/add/operator/toArray'; +import 'rxjs/add/operator/toPromise'; + + +/** +* Get TS source file based on path. +* @param filePath +* @return source file of ts.SourceFile kind +*/ +export function getSource(filePath: string): ts.SourceFile { + return ts.createSourceFile(filePath, fs.readFileSync(filePath).toString(), + ts.ScriptTarget.ES6, true); +} + + +/** + * Get all the nodes from a source, as an observable. + * @param sourceFile The source file object. + * @returns {Observable} An observable of all the nodes in the source. + */ +export function getSourceNodes(sourceFile: ts.SourceFile): Observable { + const subject = new ReplaySubject(); + let nodes: ts.Node[] = [sourceFile]; + + while(nodes.length > 0) { + const node = nodes.shift(); + + if (node) { + subject.next(node); + if (node.getChildCount(sourceFile) >= 0) { + nodes.unshift(...node.getChildren()); + } + } + } + + subject.complete(); + return subject.asObservable(); +} + + +/** + * Helper for sorting nodes. + * @return function to sort nodes in increasing order of position in sourceFile + */ +function nodesByPosition(first: ts.Node, second: ts.Node): number { + return first.pos - second.pos; +} + + +/** + * Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]` + * or after the last of occurence of `syntaxKind` if the last occurence is a sub child + * of ts.SyntaxKind[nodes[i].kind] and save the changes in file. + * + * @param nodes insert after the last occurence of nodes + * @param toInsert string to insert + * @param file file to insert changes into + * @param fallbackPos position to insert if toInsert happens to be the first occurence + * @param syntaxKind the ts.SyntaxKind of the subchildren to insert after + * @return Change instance + * @throw Error if toInsert is first occurence but fall back is not set + */ +export function insertAfterLastOccurrence(nodes: ts.Node[], toInsert: string, + file: string, fallbackPos?: number, syntaxKind?: ts.SyntaxKind): Change { + var lastItem = nodes.sort(nodesByPosition).pop(); + if (syntaxKind) { + lastItem = findNodes(lastItem, syntaxKind).sort(nodesByPosition).pop(); + } + if (!lastItem && fallbackPos == undefined) { + throw new Error(`tried to insert ${toInsert} as first occurence with no fallback position`); + } + let lastItemPosition: number = lastItem ? lastItem.end : fallbackPos; + return new InsertChange(file, lastItemPosition, toInsert); +} + + +export function getContentOfKeyLiteral(source: ts.SourceFile, node: ts.Node): string { + if (node.kind == ts.SyntaxKind.Identifier) { + return (node).text; + } else if (node.kind == ts.SyntaxKind.StringLiteral) { + try { + return JSON.parse(node.getFullText(source)) + } catch (e) { + return null; + } + } else { + return null; + } +} + + +export function getDecoratorMetadata(source: ts.SourceFile, identifier: string, + module: string): Observable { + const symbols = new Symbols(source as any); + + return getSourceNodes(source) + .filter(node => { + return node.kind == ts.SyntaxKind.Decorator + && (node).expression.kind == ts.SyntaxKind.CallExpression; + }) + .map(node => (node).expression) + .filter(expr => { + if (expr.expression.kind == ts.SyntaxKind.Identifier) { + const id = expr.expression; + const metaData = symbols.resolve(id.getFullText(source)); + if (isMetadataImportedSymbolReferenceExpression(metaData)) { + return metaData.name == identifier && metaData.module == module; + } + } else if (expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression) { + // This covers foo.NgModule when importing * as foo. + const paExpr = expr.expression; + // If the left expression is not an identifier, just give up at that point. + if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) { + return false; + } + + const id = paExpr.name; + const moduleId = paExpr.expression; + const moduleMetaData = symbols.resolve(moduleId.getFullText(source)); + if (isMetadataModuleReferenceExpression(moduleMetaData)) { + return moduleMetaData.module == module && id.getFullText(source) == identifier; + } + } + return false; + }) + .filter(expr => expr.arguments[0] + && expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression) + .map(expr => expr.arguments[0]); +} + + +function _addSymbolToNgModuleMetadata(ngModulePath: string, metadataField: string, + symbolName: string, importPath: string) { + const source: ts.SourceFile = getSource(ngModulePath); + let metadata = getDecoratorMetadata(source, 'NgModule', '@angular/core'); + + // Find the decorator declaration. + return metadata + .toPromise() + .then((node: ts.ObjectLiteralExpression) => { + if (!node) { + return null; + } + + // Get all the children property assignment of object literals. + return node.properties + .filter(prop => prop.kind == ts.SyntaxKind.PropertyAssignment) + // Filter out every fields that's not "metadataField". Also handles string literals + // (but not expressions). + .filter((prop: ts.PropertyAssignment) => { + const name = prop.name; + switch (name.kind) { + case ts.SyntaxKind.Identifier: + return (name as ts.Identifier).getText(source) == metadataField; + case ts.SyntaxKind.StringLiteral: + return (name as ts.StringLiteral).text == metadataField; + } + + return false; + }); + }) + // Get the last node of the array literal. + .then((matchingProperties: ts.ObjectLiteralElement[]): any => { + if (!matchingProperties) { + return null; + } + if (matchingProperties.length == 0) { + return metadata.toPromise(); + } + + const assignment = matchingProperties[0]; + + // If it's not an array, nothing we can do really. + if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { + return null; + } + + const arrLiteral = assignment.initializer; + if (arrLiteral.elements.length == 0) { + // Forward the property. + return arrLiteral; + } + return arrLiteral.elements; + }) + .then((node: ts.Node) => { + if (!node) { + console.log('No app module found. Please add your new class to your component.'); + return new NoopChange(); + } + if (Array.isArray(node)) { + node = node[node.length - 1]; + } + + let toInsert: string; + let position = node.getEnd(); + if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) { + // We haven't found the field in the metadata declaration. Insert a new + // field. + let expr = node; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = ` ${metadataField}: [${symbolName}]\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd(); + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.startsWith('\n')) { + toInsert = `,${text.match(/^\n(\r?)\s+/)[0]}${metadataField}: [${symbolName}]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + } else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) { + // We found the field but it's empty. Insert it just before the `]`. + position--; + toInsert = `${symbolName}`; + } else { + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.startsWith('\n')) { + toInsert = `,${text.match(/^\n(\r?)\s+/)[0]}${symbolName}`; + } else { + toInsert = `, ${symbolName}`; + } + } + + const insert = new InsertChange(ngModulePath, position, toInsert); + const importInsert: Change = insertImport(ngModulePath, symbolName, importPath); + return new MultiChange([insert, importInsert]); + }); +} + +/** +* Custom function to insert a declaration (component, pipe, directive) +* into NgModule declarations. It also imports the component. +*/ +export function addComponentToModule(modulePath: string, classifiedName: string, + importPath: string): Promise { + + return _addSymbolToNgModuleMetadata(modulePath, 'declarations', classifiedName, importPath); +} + +/** + * Custom function to insert a provider into NgModule. It also imports it. + */ +export function addProviderToModule(modulePath: string, classifiedName: string, + importPath: string): Promise { + return _addSymbolToNgModuleMetadata(modulePath, 'providers', classifiedName, importPath); +} + diff --git a/tests/acceptance/change.spec.ts b/packages/ast-tools/src/change.spec.ts similarity index 84% rename from tests/acceptance/change.spec.ts rename to packages/ast-tools/src/change.spec.ts index aaed20037394..d4abb85d5aad 100644 --- a/tests/acceptance/change.spec.ts +++ b/packages/ast-tools/src/change.spec.ts @@ -3,8 +3,8 @@ // This needs to be first so fs module can be mocked correctly. let mockFs = require('mock-fs'); -import {expect} from 'chai'; -import {InsertChange, RemoveChange, ReplaceChange} from '../../addon/ng2/utilities/change'; +import {it} from './spec-utils'; +import {InsertChange, RemoveChange, ReplaceChange} from './change'; import fs = require('fs'); let path = require('path'); @@ -38,11 +38,11 @@ describe('Change', () => { .apply() .then(() => readFile(sourceFile, 'utf8')) .then(contents => { - expect(contents).to.equal('hello world!'); + expect(contents).toEqual('hello world!'); }); }); it('fails for negative position', () => { - expect(() => new InsertChange(sourceFile, -6, ' world!')).to.throw(Error); + expect(() => new InsertChange(sourceFile, -6, ' world!')).toThrowError(); }); it('adds nothing in the source code if empty string is inserted', () => { let changeInstance = new InsertChange(sourceFile, 6, ''); @@ -50,7 +50,7 @@ describe('Change', () => { .apply() .then(() => readFile(sourceFile, 'utf8')) .then(contents => { - expect(contents).to.equal('hello'); + expect(contents).toEqual('hello'); }); }); }); @@ -64,11 +64,11 @@ describe('Change', () => { .apply() .then(() => readFile(sourceFile, 'utf8')) .then(contents => { - expect(contents).to.equal('import * from "./bar"'); + expect(contents).toEqual('import * from "./bar"'); }); }); it('fails for negative position', () => { - expect(() => new RemoveChange(sourceFile, -6, ' world!')).to.throw(Error); + expect(() => new RemoveChange(sourceFile, -6, ' world!')).toThrow(); }); it('does not change the file if told to remove empty string', () => { let changeInstance = new RemoveChange(sourceFile, 9, ''); @@ -76,7 +76,7 @@ describe('Change', () => { .apply() .then(() => readFile(sourceFile, 'utf8')) .then(contents => { - expect(contents).to.equal('import * as foo from "./bar"'); + expect(contents).toEqual('import * as foo from "./bar"'); }); }); }); @@ -89,12 +89,12 @@ describe('Change', () => { .apply() .then(() => readFile(sourceFile, 'utf8')) .then(contents => { - expect(contents).to.equal('import { fooComponent } from "./bar"'); + expect(contents).toEqual('import { fooComponent } from "./bar"'); }); }); it('fails for negative position', () => { let sourceFile = path.join(sourcePath, 'remove-replace-file.txt'); - expect(() => new ReplaceChange(sourceFile, -6, 'hello', ' world!')).to.throw(Error); + expect(() => new ReplaceChange(sourceFile, -6, 'hello', ' world!')).toThrow(); }); it('adds string to the position of an empty string', () => { let sourceFile = path.join(sourcePath, 'replace-file.txt'); @@ -103,7 +103,7 @@ describe('Change', () => { .apply() .then(() => readFile(sourceFile, 'utf8')) .then(contents => { - expect(contents).to.equal('import { BarComponent, FooComponent } from "./baz"'); + expect(contents).toEqual('import { BarComponent, FooComponent } from "./baz"'); }); }); it('removes the given string only if an empty string to add is given', () => { @@ -113,7 +113,7 @@ describe('Change', () => { .apply() .then(() => readFile(sourceFile, 'utf8')) .then(contents => { - expect(contents).to.equal('import * from "./bar"'); + expect(contents).toEqual('import * from "./bar"'); }); }); }); diff --git a/packages/ast-tools/src/change.ts b/packages/ast-tools/src/change.ts new file mode 100644 index 000000000000..174c0839cf15 --- /dev/null +++ b/packages/ast-tools/src/change.ts @@ -0,0 +1,152 @@ +import fs = require('fs'); +import denodeify = require('denodeify'); + +const readFile = (denodeify(fs.readFile) as (...args: any[]) => Promise); +const writeFile = (denodeify(fs.writeFile) as (...args: any[]) => Promise); + +export interface Change { + apply(): Promise; + + // The file this change should be applied to. Some changes might not apply to + // a file (maybe the config). + readonly path: string | null; + + // The order this change should be applied. Normally the position inside the file. + // Changes are applied from the bottom of a file to the top. + readonly order: number; + + // The description of this change. This will be outputted in a dry or verbose run. + readonly description: string; +} + + +/** + * An operation that does nothing. + */ +export class NoopChange implements Change { + description = 'No operation.'; + order = Infinity; + path: string = null; + apply() { return Promise.resolve(); } +} + +/** + * An operation that mixes two or more changes, and merge them (in order). + * Can only apply to a single file. Use a ChangeManager to apply changes to multiple + * files. + */ +export class MultiChange implements Change { + private _path: string; + private _changes: Change[]; + + constructor(...changes: (Change[] | Change)[]) { + this._changes = []; + [].concat(...changes).forEach(change => this.appendChange(change)); + } + + appendChange(change: Change) { + // Validate that the path is the same for everyone of those. + if (this._path === undefined) { + this._path = change.path; + } else if (change.path !== this._path) { + throw new Error('Cannot apply a change to a different path.'); + } + this._changes.push(change); + } + + get description() { + return `Changes:\n ${this._changes.map(x => x.description).join('\n ')}`; + } + // Always apply as early as the highest change. + get order() { return Math.max(...this._changes.map(c => c.order)); } + get path() { return this._path; } + + apply() { + return this._changes + .sort((a: Change, b: Change) => b.order - a.order) + .reduce((promise, change) => { + return promise.then(() => change.apply()) + }, Promise.resolve()); + } +} + + +/** + * Will add text to the source code. + */ +export class InsertChange implements Change { + + order: number; + description: string; + + constructor(public path: string, private pos: number, private toAdd: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Inserted ${toAdd} into position ${pos} of ${path}`; + this.order = pos; + } + + /** + * This method does not insert spaces if there is none in the original string. + */ + apply(): Promise { + return readFile(this.path, 'utf8').then(content => { + let prefix = content.substring(0, this.pos); + let suffix = content.substring(this.pos); + return writeFile(this.path, `${prefix}${this.toAdd}${suffix}`); + }); + } +} + +/** + * Will remove text from the source code. + */ +export class RemoveChange implements Change { + + order: number; + description: string; + + constructor(public path: string, private pos: number, private toRemove: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.order = pos; + } + + apply(): Promise { + return readFile(this.path, 'utf8').then(content => { + let prefix = content.substring(0, this.pos); + let suffix = content.substring(this.pos + this.toRemove.length); + // TODO: throw error if toRemove doesn't match removed string. + return writeFile(this.path, `${prefix}${suffix}`); + }); + } +} + +/** + * Will replace text from the source code. + */ +export class ReplaceChange implements Change { + order: number; + description: string; + + constructor(public path: string, private pos: number, private oldText: string, + private newText: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`; + this.order = pos; + } + + apply(): Promise { + return readFile(this.path, 'utf8').then(content => { + let prefix = content.substring(0, this.pos); + let suffix = content.substring(this.pos + this.oldText.length); + // TODO: throw error if oldText doesn't match removed string. + return writeFile(this.path, `${prefix}${this.newText}${suffix}`); + }); + } +} diff --git a/packages/ast-tools/src/index.ts b/packages/ast-tools/src/index.ts new file mode 100644 index 000000000000..8c2b89852e23 --- /dev/null +++ b/packages/ast-tools/src/index.ts @@ -0,0 +1,3 @@ +export * from './ast-utils'; +export * from './change'; +export * from './node'; diff --git a/packages/ast-tools/src/node.ts b/packages/ast-tools/src/node.ts new file mode 100644 index 000000000000..e734c941b978 --- /dev/null +++ b/packages/ast-tools/src/node.ts @@ -0,0 +1,47 @@ +import ts = require('typescript'); +import {RemoveChange, Change} from './change'; + + +/** + * Find all nodes from the AST in the subtree of node of SyntaxKind kind. + * @param node + * @param kind + * @param max The maximum number of items to return. + * @return all nodes of kind, or [] if none is found + */ +export function findNodes(node: ts.Node, kind: ts.SyntaxKind, max: number = Infinity): ts.Node[] { + if (!node || max == 0) { + return []; + } + + let arr: ts.Node[] = []; + if (node.kind === kind) { + arr.push(node); + max--; + } + if (max > 0) { + for (const child of node.getChildren()) { + findNodes(child, kind, max).forEach(node => { + if (max > 0) { + arr.push(node); + } + max--; + }); + + if (max <= 0) { + break; + } + } + } + return arr; +} + + +export function removeAstNode(node: ts.Node): Change { + const source = node.getSourceFile(); + return new RemoveChange( + source.path, + node.getStart(source), + node.getFullText(source) + ); +} diff --git a/tests/acceptance/route-utils.spec.ts b/packages/ast-tools/src/route-utils.spec.ts similarity index 62% rename from tests/acceptance/route-utils.spec.ts rename to packages/ast-tools/src/route-utils.spec.ts index 6d1a7c48f8dd..c22a48fad995 100644 --- a/tests/acceptance/route-utils.spec.ts +++ b/packages/ast-tools/src/route-utils.spec.ts @@ -1,14 +1,14 @@ 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 nru from './route-utils'; import * as path from 'path'; -import { InsertChange, RemoveChange } from '../../addon/ng2/utilities/change'; -import * as Promise from 'ember-cli/lib/ext/promise'; +import { InsertChange, RemoveChange } from './change'; +import denodeify = require('denodeify'); import * as _ from 'lodash'; +import {it} from './spec-utils'; + +const readFile = (denodeify(fs.readFile) as (...args: any[]) => Promise); -const readFile = Promise.denodeify(fs.readFile); describe('route utils', () => { describe('insertImport', () => { @@ -30,76 +30,76 @@ describe('route utils', () => { 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';`); - }); + .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply()) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).toEqual(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); - }); + .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router')) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).toEqual(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'`); - }); + .then(() => nru.insertImport(sourceFile, 'baz', 'fizz').apply()) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).toEqual(`'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); - }); + .then(() => nru.insertImport(sourceFile, 'Test', 'tests')) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).toEqual(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`); - }); + .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply()) + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).toEqual( + `'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`); - }); + .then(() => readFile(sourceFile, 'utf8')) + .then(newContent => { + expect(newContent).toEqual(`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`; + `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`; + `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);' + `import { AppComponent } from './app/'; \n` + + 'bootstrap(AppComponent);' } }; mockFs(mockDrive); @@ -111,107 +111,107 @@ describe('route utils', () => { 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) ]);'); - }); + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).toEqual(prefix + routerImport + + 'bootstrap(AppComponent, [ provideRouter(routes) ]);'); + }); }); - it('does not add a provideRouter import if it exits already', () => { + xit('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'; + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).toEqual( + `import routes from './routes'; import { provideRouter } from '@angular/router'; bootstrap(AppComponent, [ provideRouter(routes) ]);`); - }); + }); }); - it('does not duplicate import to route.ts ', () => { + xit('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) ]);'); - }); + .apply() + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).toEqual(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) ]);'); - }); + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).toEqual(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)]);'); - }); + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).toEqual(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) ]);'); - }); + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).toEqual(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) ]);'); - }); + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).toEqual(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)]);'); - }); + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).toEqual(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') - ); + .then(() => nru.bootstrapItem(mainFile, routes, toBootstrap)) + .catch(e => + expect(e.message).toEqual('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});'); - }); + .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) + .then(() => readFile(mainFile, 'utf8')) + .then(content => { + expect(content).toEqual(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'}; + component: 'NewRouteComponent', dasherizedName: 'new-route'}; const nestedRoutes = `\n { path: 'home', component: HomeComponent, children: [ { path: 'about', component: AboutComponent, @@ -236,20 +236,20 @@ describe('route utils', () => { 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'; + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + expect(content).toEqual( + `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`); + expect(e.message).toEqual('Did not insert path in routes.ts because ' + + `there were multiple or no 'export default' statements`); }); }); it('throws error if no export defaults exists', () => { @@ -257,28 +257,28 @@ export default [\n { path: 'new-route', component: NewRouteComponent }\n];`); 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`); + expect(e.message).toEqual('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`); + `\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];`); - }); + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + expect(content).toEqual( + `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); @@ -287,9 +287,9 @@ export default [\n { path: 'new-route', component: NewRouteComponent }\n];`); 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'; + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + let expected = `import { DetailsComponent } from './app/home/about/details/details.component'; export default [ { path: 'home', component: HomeComponent, children: [ @@ -301,8 +301,8 @@ export default [ } ] }\n];`; - expect(content).to.equal(expected); - }); + expect(content).toEqual(expected); + }); }); it('inserts under parent, deep', () => { let editedFile = new InsertChange(routesFile, 16, nestedRoutes); @@ -311,9 +311,9 @@ export default [ 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'; + .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: [ @@ -329,8 +329,8 @@ export default [ ] } ];`; - expect(content).to.equal(expected); - }); + expect(content).toEqual(expected); + }); }); it('works well with multiple routes in a level', () => { let paths = `\n { path: 'main', component: MainComponent } @@ -345,9 +345,9 @@ export default [ 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'; + .then(() => readFile(routesFile, 'utf8')) + .then(content => { + expect(content).toEqual(`import { AboutComponent_1 } from './app/home/about/about.component'; export default [ { path: 'main', component: MainComponent } { path: 'home', component: HomeComponent, @@ -357,17 +357,17 @@ export default [ ] } ];` - ); - }); + ); + }); }); 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)); + return nru.addPathToRoutes(routesFile, _.merge({route: '/home'}, options)); }).catch(e => { - expect(e.message).to.equal('Route was not added since it is a duplicate'); + expect(e.message).toEqual('Route was not added since it is a duplicate'); }); }); it('throws error if repeating child, mid', () => { @@ -375,9 +375,9 @@ export default [ return editedFile.apply().then(() => { options.dasherizedName = 'about'; options.component = 'AboutComponent'; - return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/'}, options)); + 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'); + expect(e.message).toEqual('Route was not added since it is a duplicate'); }); }); it('throws error if repeating child, deep', () => { @@ -385,9 +385,9 @@ export default [ return editedFile.apply().then(() => { options.dasherizedName = 'more'; options.component = 'MoreComponent'; - return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/more'}, options)); + 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'); + expect(e.message).toEqual('Route was not added since it is a duplicate'); }); }); it('does not report false repeat', () => { @@ -397,9 +397,9 @@ export default [ 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'; + .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, @@ -411,8 +411,8 @@ export default [ } ] }\n];`; - expect(content).to.equal(expected); - }); + expect(content).toEqual(expected); + }); }); it('does not report false repeat: multiple paths on a level', () => { @@ -431,8 +431,9 @@ export default [ options.dasherizedName = 'trap-queen'; options.component = 'TrapQueenComponent'; return nru.applyChanges( - nru.addPathToRoutes(routesFile, _.merge({route: 'home/trap-queen'}, options))); }) - .then(() => readFile(routesFile, 'utf8') + 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 [ @@ -446,25 +447,25 @@ export default [ } ] },\n { path: 'trap-queen', component: TrapQueenComponent}\n];`; - expect(content).to.equal(expected); + expect(content).toEqual(expected); }); }); it('resolves imports correctly', () => { let editedFile = new InsertChange(routesFile, 16, - `\n { path: 'home', component: HomeComponent }\n`); + `\n { path: 'home', component: HomeComponent }\n`); return editedFile.apply().then(() => { let editedFile = new InsertChange(routesFile, 0, - `import { HomeComponent } from './app/home/home.component';\n`); + `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'; + .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, @@ -473,12 +474,12 @@ export default [ ] } ];`; - expect(content).to.equal(expected); - }); + expect(content).toEqual(expected); + }); }); it('throws error if components collide and there is repitition', () => { let editedFile = new InsertChange(routesFile, 16, -`\n { path: 'about', component: AboutComponent, + `\n { path: 'about', component: AboutComponent, children: [ { path: 'details/:id', component: DetailsComponent_1 }, { path: 'details', component: DetailsComponent } @@ -486,7 +487,7 @@ export default [ }`); return editedFile.apply().then(() => { let editedFile = new InsertChange(routesFile, 0, -`import { AboutComponent } from './app/about/about.component'; + `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(); @@ -494,7 +495,7 @@ import { DetailsComponent as DetailsComponent_1 } from './app/about/description/ options.dasherizedName = 'details'; options.component = 'DetailsComponent'; expect(() => nru.addPathToRoutes(routesFile, _.merge({route: 'about/details'}, options))) - .to.throw(Error); + .toThrowError(); }); }); @@ -503,11 +504,11 @@ import { DetailsComponent as DetailsComponent_1 } from './app/about/description/ let editedFile = new InsertChange(routesFile, 16, path); return editedFile.apply().then(() => { let toInsert = {'home': ['canActivate', '[ MyGuard ]'] }; - return nru.applyChanges(nru.addItemsToRouteProperties(routesFile, toInsert)); }) + return nru.applyChanges(nru.addItemsToRouteProperties(routesFile, toInsert)); + }) .then(() => readFile(routesFile, 'utf8')) .then(content => { - expect(content).to.equal( -`export default [ + expect(content).toEqual(`export default [ { path: 'home', component: HomeComponent, canActivate: [ MyGuard ] } ];` ); @@ -521,16 +522,16 @@ import { DetailsComponent as DetailsComponent_1 } from './app/about/description/ 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'; + .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).toEqual( + `import { MoreComponent } from './app/home/more/more.component'; export default [ { path: 'home', component: HomeComponent, children: [ @@ -538,8 +539,8 @@ export default [ ] } ];` - ); - }); + ); + }); }); }); @@ -565,37 +566,37 @@ export default [ it('accepts component name without \'component\' suffix: resolveComponentPath', () => { let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about'); - expect(fileName).to.equal(componentFile); + expect(fileName).toEqual(componentFile); }); it('accepts component name with \'component\' suffix: resolveComponentPath', () => { let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about.component'); - expect(fileName).to.equal(componentFile); + expect(fileName).toEqual(componentFile); }); it('accepts path absolute from project root: resolveComponentPath', () => { let fileName = nru.resolveComponentPath(projectRoot, '', `${path.sep}about`); - expect(fileName).to.equal(componentFile); + expect(fileName).toEqual(componentFile); }); it('accept component with directory name: resolveComponentPath', () => { let fileName = nru.resolveComponentPath(projectRoot, 'src/app', 'about/about.component'); - expect(fileName).to.equal(componentFile); + expect(fileName).toEqual(componentFile); }); it('finds component name: confirmComponentExport', () => { let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent'); - expect(exportExists).to.be.truthy; + expect(exportExists).toBeTruthy(); }); 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; + expect(exportExists).toBeTruthy(); }); }); 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; + expect(exportExists).not.toBeTruthy(); }); }); }); diff --git a/packages/ast-tools/src/route-utils.ts b/packages/ast-tools/src/route-utils.ts new file mode 100644 index 000000000000..bc2c86fe5154 --- /dev/null +++ b/packages/ast-tools/src/route-utils.ts @@ -0,0 +1,529 @@ +import * as ts from 'typescript'; +import * as fs from 'fs'; +import * as path from 'path'; +import {Change, InsertChange, NoopChange} from './change'; +import {findNodes} from './node'; +import {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: string, 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].toString(), + 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) as ts.Identifier).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 new NoopChange(); + } + + // no such import declaration exists + let useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral) + .filter((n: ts.StringLiteral) => 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: any): Change[] { + let route = pathOptions.route.split('/') + .filter((n: string) => 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: ts.Node; + if (pathOptions.parent) { + // append '_' to route to find the actual parent (not parent of the parent) + parent = getParent(routesArray, `${pathOptions.parent}/_`); + 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 routes Object {route: [key, value]} + */ +export function addItemsToRouteProperties(routesFile: string, routes: {[key: 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: ts.Identifier) => 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() as ts.StringLiteral).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.order - curr.order) + .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: 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 as ts.PropertyAssignment).name as ts.Identifier).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) as ts.Identifier).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 as ts.PropertyAssignment).name as ts.Identifier).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.Node, key: string) { + let currentNode = key === 'component' ? objectLiteralNode.getChildAt(1).getChildAt(2) : + objectLiteralNode.getChildAt(1).getChildAt(0); + return currentNode + && currentNode.getChildAt(0) + && (currentNode.getChildAt(0) as ts.Identifier).text === key + && currentNode.getChildAt(2) + && (currentNode.getChildAt(2) as ts.Identifier).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/packages/ast-tools/src/spec-utils.ts b/packages/ast-tools/src/spec-utils.ts new file mode 100644 index 000000000000..4db51445a032 --- /dev/null +++ b/packages/ast-tools/src/spec-utils.ts @@ -0,0 +1,26 @@ +// This file exports a version of the Jasmine `it` that understands promises. +// To use this, simply `import {it} from './spec-utils`. +// TODO(hansl): move this to its own Jasmine-TypeScript package. + +function async(fn: () => PromiseLike | void) { + return (done: DoneFn) => { + let result: PromiseLike | void = null; + + try { + result = fn(); + + if (result && 'then' in result) { + (result as Promise).then(done, done.fail); + } else { + done(); + } + } catch (err) { + done.fail(err); + } + }; +} + + +export function it(description: string, fn: () => PromiseLike | void) { + return (global as any)['it'](description, async(fn)); +} diff --git a/packages/ast-tools/tsconfig.json b/packages/ast-tools/tsconfig.json new file mode 100644 index 000000000000..2361b5810105 --- /dev/null +++ b/packages/ast-tools/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "declaration": true, + "experimentalDecorators": true, + "mapRoot": "", + "module": "commonjs", + "moduleResolution": "node", + "noEmitOnError": true, + "noImplicitAny": true, + "outDir": "../../dist/ast-tools", + "rootDir": ".", + "sourceMap": true, + "sourceRoot": "/", + "target": "es5", + "lib": ["es6"], + "typeRoots": [ + "../../node_modules/@types" + ], + "types": [ + "jasmine", + "node" + ] + } +} diff --git a/scripts/run-packages-spec.js b/scripts/run-packages-spec.js new file mode 100644 index 000000000000..07f18f5140cb --- /dev/null +++ b/scripts/run-packages-spec.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +'use strict'; + +require('../lib/bootstrap-local'); + +const path = require('path'); +const Jasmine = require('jasmine'); +const JasmineSpecReporter = require('jasmine-spec-reporter'); + +const projectBaseDir = path.join(__dirname, '../packages'); + +// Create a Jasmine runner and configure it. +const jasmine = new Jasmine({ projectBaseDir: projectBaseDir }); +jasmine.loadConfig({ + spec_dir: projectBaseDir +}); +jasmine.addReporter(new JasmineSpecReporter()); + +// Run the tests. +jasmine.execute(['**/*.spec.ts']); diff --git a/tests/runner.js b/tests/runner.js index 2cfdb58071fe..9387db81161f 100644 --- a/tests/runner.js +++ b/tests/runner.js @@ -1,34 +1,7 @@ /* eslint-disable no-console */ 'use strict'; -const fs = require('fs'); -const ts = require('typescript'); -const old = require.extensions['.ts']; - -require.extensions['.ts'] = function(m, filename) { - if (!filename.match(/angular-cli/) && filename.match(/node_modules/)) { - if (old) { - return old(m, filename); - } - return m._compile(fs.readFileSync(filename), filename); - } - - const source = fs.readFileSync(filename).toString(); - - try { - const result = ts.transpile(source, { - target: ts.ScriptTarget.ES5, - module: ts.ModuleKind.CommonJs - }); - - // Send it to node to execute. - return m._compile(result, filename); - } catch (err) { - console.error('Error while running script "' + filename + '":'); - console.error(err.stack); - throw err; - } -}; +require('../lib/bootstrap-local'); var Mocha = require('mocha'); var glob = require('glob');