diff --git a/lib/bootstrap-local.js b/lib/bootstrap-local.js index 1712a756116c..34b7a28c7a47 100644 --- a/lib/bootstrap-local.js +++ b/lib/bootstrap-local.js @@ -7,6 +7,7 @@ const ts = require('typescript'); global.angularCliIsLocal = true; +global.angularCliPackages = require('./packages'); const compilerOptions = JSON.parse(fs.readFileSync(path.join(__dirname, '../tsconfig.json'))); diff --git a/lib/packages.js b/lib/packages.js index ccb0e67215d8..204f71dab448 100644 --- a/lib/packages.js +++ b/lib/packages.js @@ -11,8 +11,11 @@ const packages = fs.readdirSync(packageRoot) .map(pkgName => ({ name: pkgName, root: path.join(packageRoot, pkgName) })) .filter(pkg => fs.statSync(pkg.root).isDirectory()) .reduce((packages, pkg) => { - let name = pkg == 'angular-cli' ? 'angular-cli' : `@angular-cli/${pkg.name}`; + let pkgJson = JSON.parse(fs.readFileSync(path.join(pkg.root, 'package.json'), 'utf8')); + let name = pkgJson['name']; packages[name] = { + dist: path.join(__dirname, '../dist', pkg.name), + packageJson: path.join(pkg.root, 'package.json'), root: pkg.root, main: path.resolve(pkg.root, 'src/index.ts') }; diff --git a/package.json b/package.json index d9cf4d873e74..8175a2c0bd5d 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "fs.realpath": "^1.0.0", "glob": "^7.0.3", "handlebars": "^4.0.5", + "html-loader": "^0.4.4", "html-webpack-plugin": "^2.19.0", "istanbul-instrumenter-loader": "^0.2.0", "json-loader": "^0.5.4", diff --git a/packages/angular-cli/blueprints/component/index.js b/packages/angular-cli/blueprints/component/index.js index 3f671370397e..e037aa260839 100644 --- a/packages/angular-cli/blueprints/component/index.js +++ b/packages/angular-cli/blueprints/component/index.js @@ -1,11 +1,12 @@ -var path = require('path'); -var chalk = require('chalk'); -var Blueprint = require('ember-cli/lib/models/blueprint'); -var dynamicPathParser = require('../../utilities/dynamic-path-parser'); +const path = require('path'); +const chalk = require('chalk'); +const Blueprint = require('ember-cli/lib/models/blueprint'); +const dynamicPathParser = require('../../utilities/dynamic-path-parser'); const findParentModule = require('../../utilities/find-parent-module').default; -var getFiles = Blueprint.prototype.files; +const getFiles = Blueprint.prototype.files; const stringUtils = require('ember-cli-string-utils'); const astUtils = require('../../utilities/ast-utils'); +const NodeHost = require('@angular-cli/ast-tools').NodeHost; module.exports = { description: '', @@ -117,7 +118,7 @@ module.exports = { if (!options['skip-import']) { returns.push( astUtils.addDeclarationToModule(this.pathToModule, className, importPath) - .then(change => change.apply())); + .then(change => change.apply(NodeHost))); } return Promise.all(returns); diff --git a/packages/angular-cli/blueprints/directive/index.js b/packages/angular-cli/blueprints/directive/index.js index db8adc6487bc..60d029aabbab 100644 --- a/packages/angular-cli/blueprints/directive/index.js +++ b/packages/angular-cli/blueprints/directive/index.js @@ -3,6 +3,7 @@ var dynamicPathParser = require('../../utilities/dynamic-path-parser'); const stringUtils = require('ember-cli-string-utils'); const astUtils = require('../../utilities/ast-utils'); const findParentModule = require('../../utilities/find-parent-module').default; +const NodeHost = require('@angular-cli/ast-tools').NodeHost; module.exports = { description: '', @@ -73,7 +74,7 @@ module.exports = { if (!options['skip-import']) { returns.push( astUtils.addDeclarationToModule(this.pathToModule, className, importPath) - .then(change => change.apply())); + .then(change => change.apply(NodeHost))); } return Promise.all(returns); diff --git a/packages/angular-cli/blueprints/pipe/index.js b/packages/angular-cli/blueprints/pipe/index.js index 13f4d0902891..bc4b0710ca60 100644 --- a/packages/angular-cli/blueprints/pipe/index.js +++ b/packages/angular-cli/blueprints/pipe/index.js @@ -3,6 +3,7 @@ var dynamicPathParser = require('../../utilities/dynamic-path-parser'); const stringUtils = require('ember-cli-string-utils'); const astUtils = require('../../utilities/ast-utils'); const findParentModule = require('../../utilities/find-parent-module').default; +const NodeHost = require('@angular-cli/ast-tools').NodeHost; module.exports = { description: '', @@ -61,7 +62,7 @@ module.exports = { if (!options['skip-import']) { returns.push( astUtils.addDeclarationToModule(this.pathToModule, className, importPath) - .then(change => change.apply())); + .then(change => change.apply(NodeHost))); } return Promise.all(returns); diff --git a/packages/angular-cli/commands/build.ts b/packages/angular-cli/commands/build.ts index b58acf7e8bf7..744b405fe567 100644 --- a/packages/angular-cli/commands/build.ts +++ b/packages/angular-cli/commands/build.ts @@ -10,6 +10,7 @@ export interface BuildOptions { watcher?: string; supressSizes: boolean; baseHref?: string; + aot?: boolean; } const BuildCommand = Command.extend({ @@ -30,6 +31,7 @@ const BuildCommand = Command.extend({ { name: 'watcher', type: String }, { name: 'suppress-sizes', type: Boolean, default: false }, { name: 'base-href', type: String, default: null, aliases: ['bh'] }, + { name: 'aot', type: Boolean, default: false } ], run: function (commandOptions: BuildOptions) { diff --git a/packages/angular-cli/commands/serve.ts b/packages/angular-cli/commands/serve.ts index da67a503fb3a..936a341b4cfc 100644 --- a/packages/angular-cli/commands/serve.ts +++ b/packages/angular-cli/commands/serve.ts @@ -25,6 +25,7 @@ export interface ServeTaskOptions { ssl?: boolean; sslKey?: string; sslCert?: string; + aot?: boolean; } const ServeCommand = Command.extend({ @@ -77,7 +78,8 @@ const ServeCommand = Command.extend({ { name: 'environment', type: String, default: '', aliases: ['e'] }, { name: 'ssl', type: Boolean, default: false }, { name: 'ssl-key', type: String, default: 'ssl/server.key' }, - { name: 'ssl-cert', type: String, default: 'ssl/server.crt' } + { name: 'ssl-cert', type: String, default: 'ssl/server.crt' }, + { name: 'aot', type: Boolean, default: false } ], run: function(commandOptions: ServeTaskOptions) { diff --git a/packages/angular-cli/models/webpack-build-common.ts b/packages/angular-cli/models/webpack-build-common.ts index 6de3b9d3f6c0..a11514fcd1db 100644 --- a/packages/angular-cli/models/webpack-build-common.ts +++ b/packages/angular-cli/models/webpack-build-common.ts @@ -1,11 +1,9 @@ +import * as webpack from 'webpack'; import * as path from 'path'; +import {BaseHrefWebpackPlugin} from '@angular-cli/base-href-webpack'; + const CopyWebpackPlugin = require('copy-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -import * as webpack from 'webpack'; -const atl = require('awesome-typescript-loader'); - -import { BaseHrefWebpackPlugin } from '@angular-cli/base-href-webpack'; -import { findLazyModules } from './find-lazy-modules'; export function getWebpackCommonConfig( @@ -23,7 +21,6 @@ export function getWebpackCommonConfig( const scripts = appConfig.scripts ? appConfig.scripts.map((script: string) => path.resolve(appRoot, script)) : []; - const lazyModules = findLazyModules(appRoot); let entry: { [key: string]: string[] } = { main: [appMain] @@ -56,21 +53,7 @@ export function getWebpackCommonConfig( } ], loaders: [ - { - test: /\.ts$/, - loaders: [ - { - loader: 'awesome-typescript-loader', - query: { - useForkChecker: true, - tsconfig: path.resolve(appRoot, appConfig.tsconfig) - } - }, { - loader: 'angular2-template-loader' - } - ], - exclude: [/\.(spec|e2e)\.ts$/] - }, + // TypeScript loaders are separated into webpack-build-typescript. // in main, load css as raw text        { @@ -115,7 +98,7 @@ export function getWebpackCommonConfig(        { test: /\.json$/, loader: 'json-loader' },        { test: /\.(jpg|png|gif)$/, loader: 'url-loader?limit=10000' }, -        { test: /\.html$/, loader: 'raw-loader' }, +        { test: /\.html$/, loader: 'html-loader' }, { test: /\.(otf|woff|ttf|svg)$/, loader: 'url?limit=10000' }, { test: /\.woff2$/, loader: 'url?limit=10000&mimetype=font/woff2' }, @@ -123,8 +106,6 @@ export function getWebpackCommonConfig( ] }, plugins: [ - new webpack.ContextReplacementPlugin(/.*/, appRoot, lazyModules), - new atl.ForkCheckerPlugin(), new HtmlWebpackPlugin({ template: path.resolve(appRoot, appConfig.index), chunksSortMode: 'dependency' diff --git a/packages/angular-cli/models/webpack-build-typescript.ts b/packages/angular-cli/models/webpack-build-typescript.ts new file mode 100644 index 000000000000..9ed49ecfc9ab --- /dev/null +++ b/packages/angular-cli/models/webpack-build-typescript.ts @@ -0,0 +1,63 @@ +import * as path from 'path'; +import * as webpack from 'webpack'; +import {findLazyModules} from './find-lazy-modules'; +import {NgcWebpackPlugin} from '@ngtools/webpack'; + +const atl = require('awesome-typescript-loader'); + +const g: any = global; +const webpackLoader: string = g['angularCliIsLocal'] + ? g.angularCliPackages['@ngtools/webpack'].main + : '@ngtools/webpack'; + + +export const getWebpackNonAotConfigPartial = function(projectRoot: string, appConfig: any) { + const appRoot = path.resolve(projectRoot, appConfig.root); + const lazyModules = findLazyModules(appRoot); + + return { + module: { + loaders: [ + { + test: /\.ts$/, + loaders: [{ + loader: 'awesome-typescript-loader', + query: { + useForkChecker: true, + tsconfig: path.resolve(appRoot, appConfig.tsconfig) + } + }, { + loader: 'angular2-template-loader' + }], + exclude: [/\.(spec|e2e)\.ts$/] + } + ], + }, + plugins: [ + new webpack.ContextReplacementPlugin(/.*/, appRoot, lazyModules), + new atl.ForkCheckerPlugin(), + ] + }; +}; + +export const getWebpackAotConfigPartial = function(projectRoot: string, appConfig: any) { + return { + module: { + loaders: [ + { + test: /\.ts$/, + loader: webpackLoader, + exclude: [/\.(spec|e2e)\.ts$/] + } + ] + }, + plugins: [ + new NgcWebpackPlugin({ + project: path.resolve(projectRoot, appConfig.root, appConfig.tsconfig), + baseDir: path.resolve(projectRoot, ''), + entryModule: path.join(projectRoot, appConfig.root, 'app/app.module#AppModule'), + genDir: path.join(projectRoot, appConfig.outDir, 'ngfactory') + }), + ] + }; +}; diff --git a/packages/angular-cli/models/webpack-config.ts b/packages/angular-cli/models/webpack-config.ts index 85c8c90fea54..6317fef937a3 100644 --- a/packages/angular-cli/models/webpack-config.ts +++ b/packages/angular-cli/models/webpack-config.ts @@ -1,3 +1,7 @@ +import { + getWebpackAotConfigPartial, + getWebpackNonAotConfigPartial +} from './webpack-build-typescript'; const webpackMerge = require('webpack-merge'); import { CliConfig } from './config'; import { @@ -12,50 +16,54 @@ export class NgCliWebpackConfig { // TODO: When webpack2 types are finished lets replace all these any types // so this is more maintainable in the future for devs public config: any; - private devConfigPartial: any; - private prodConfigPartial: any; - private baseConfig: any; constructor( public ngCliProject: any, public target: string, public environment: string, outputDir?: string, - baseHref?: string + baseHref?: string, + isAoT = false ) { const config: CliConfig = CliConfig.fromProject(); const appConfig = config.config.apps[0]; appConfig.outDir = outputDir || appConfig.outDir; - this.baseConfig = getWebpackCommonConfig( + let baseConfig = getWebpackCommonConfig( this.ngCliProject.root, environment, appConfig, baseHref ); - this.devConfigPartial = getWebpackDevConfigPartial(this.ngCliProject.root, appConfig); - this.prodConfigPartial = getWebpackProdConfigPartial(this.ngCliProject.root, appConfig); + let targetConfigPartial = this.getTargetConfig(this.ngCliProject.root, appConfig); + const typescriptConfigPartial = isAoT + ? getWebpackAotConfigPartial(this.ngCliProject.root, appConfig) + : getWebpackNonAotConfigPartial(this.ngCliProject.root, appConfig); if (appConfig.mobile) { let mobileConfigPartial = getWebpackMobileConfigPartial(this.ngCliProject.root, appConfig); let mobileProdConfigPartial = getWebpackMobileProdConfigPartial(this.ngCliProject.root, appConfig); - this.baseConfig = webpackMerge(this.baseConfig, mobileConfigPartial); - this.prodConfigPartial = webpackMerge(this.prodConfigPartial, mobileProdConfigPartial); + baseConfig = webpackMerge(baseConfig, mobileConfigPartial); + if (this.target == 'production') { + targetConfigPartial = webpackMerge(targetConfigPartial, mobileProdConfigPartial); + } } - this.generateConfig(); + this.config = webpackMerge( + baseConfig, + targetConfigPartial, + typescriptConfigPartial + ); } - generateConfig(): void { + getTargetConfig(projectRoot: string, appConfig: any): any { switch (this.target) { case 'development': - this.config = webpackMerge(this.baseConfig, this.devConfigPartial); - break; + return getWebpackDevConfigPartial(projectRoot, appConfig); case 'production': - this.config = webpackMerge(this.baseConfig, this.prodConfigPartial); - break; + return getWebpackProdConfigPartial(projectRoot, appConfig); default: throw new Error("Invalid build target. Only 'development' and 'production' are available."); } diff --git a/packages/angular-cli/package.json b/packages/angular-cli/package.json index b3ff8a171626..55752eadc976 100644 --- a/packages/angular-cli/package.json +++ b/packages/angular-cli/package.json @@ -33,6 +33,7 @@ "@angular/platform-browser": "^2.0.0", "@angular/platform-server": "^2.0.0", "@angular/tsc-wrapped": "^0.3.0", + "@ngtools/webpack": "latest", "angular2-template-loader": "^0.5.0", "awesome-typescript-loader": "^2.2.3", "chalk": "^1.1.3", @@ -53,6 +54,7 @@ "fs.realpath": "^1.0.0", "glob": "^7.0.3", "handlebars": "^4.0.5", + "html-loader": "^0.4.4", "html-webpack-plugin": "^2.19.0", "istanbul-instrumenter-loader": "^0.2.0", "json-loader": "^0.5.4", diff --git a/packages/angular-cli/tasks/build-webpack-watch.ts b/packages/angular-cli/tasks/build-webpack-watch.ts index a85d4f1112c5..008168dac399 100644 --- a/packages/angular-cli/tasks/build-webpack-watch.ts +++ b/packages/angular-cli/tasks/build-webpack-watch.ts @@ -21,7 +21,8 @@ export default Task.extend({ runTaskOptions.target, runTaskOptions.environment, runTaskOptions.outputPath, - runTaskOptions.baseHref + runTaskOptions.baseHref, + runTaskOptions.aot ).config; const webpackCompiler: any = webpack(config); diff --git a/packages/angular-cli/tasks/build-webpack.ts b/packages/angular-cli/tasks/build-webpack.ts index 148ee583dd5d..88941955247b 100644 --- a/packages/angular-cli/tasks/build-webpack.ts +++ b/packages/angular-cli/tasks/build-webpack.ts @@ -17,13 +17,13 @@ export default Task.extend({ const outputDir = runTaskOptions.outputPath || CliConfig.fromProject().config.apps[0].outDir; rimraf.sync(path.resolve(project.root, outputDir)); - const config = new NgCliWebpackConfig( project, runTaskOptions.target, runTaskOptions.environment, outputDir, - runTaskOptions.baseHref + runTaskOptions.baseHref, + runTaskOptions.aot ).config; // fail on build error diff --git a/packages/angular-cli/tasks/serve-webpack.ts b/packages/angular-cli/tasks/serve-webpack.ts index a227220fcc97..f09e21de0dca 100644 --- a/packages/angular-cli/tasks/serve-webpack.ts +++ b/packages/angular-cli/tasks/serve-webpack.ts @@ -19,8 +19,12 @@ export default Task.extend({ let webpackCompiler: any; let config = new NgCliWebpackConfig( - this.project, commandOptions.target, - commandOptions.environment + this.project, + commandOptions.target, + commandOptions.environment, + undefined, + undefined, + commandOptions.aot ).config; // This allows for live reload of page when changes are made to repo. diff --git a/packages/angular-cli/tsconfig.json b/packages/angular-cli/tsconfig.json index 0e08554fd4c8..9623f183e150 100644 --- a/packages/angular-cli/tsconfig.json +++ b/packages/angular-cli/tsconfig.json @@ -8,22 +8,26 @@ "moduleResolution": "node", "noEmitOnError": true, "noImplicitAny": true, - "outDir": "../../dist/", - "rootDir": "..", + "outDir": "../../dist/angular-cli", + "rootDir": ".", "sourceMap": true, "sourceRoot": "/", "target": "es5", "lib": ["es6"], + "skipLibCheck": true, "typeRoots": [ "../../node_modules/@types" ], "baseUrl": "", "paths": { - "@angular-cli/ast-tools": [ "../../packages/ast-tools/src" ], - "@angular-cli/base-href-webpack": [ "../../packages/base-href-webpack/src" ], - "@angular-cli/webpack": [ "../../packages/webpack/src" ] + "@angular-cli/ast-tools": [ "../../dist/ast-tools/src" ], + "@angular-cli/base-href-webpack": [ "../../dist/base-href-webpack/src" ], + "@ngtools/webpack": [ "../../dist/webpack/src" ] } }, + "include": [ + "**/*" + ], "exclude": [ "blueprints/*/files/**/*" ] diff --git a/packages/angular-cli/utilities/module-resolver.ts b/packages/angular-cli/utilities/module-resolver.ts index 3b889116b53d..020b25a11389 100644 --- a/packages/angular-cli/utilities/module-resolver.ts +++ b/packages/angular-cli/utilities/module-resolver.ts @@ -5,7 +5,8 @@ import * as ts from 'typescript'; import * as dependentFilesUtils from './get-dependent-files'; -import { Change, ReplaceChange } from './change'; +import {Change, ReplaceChange} from './change'; +import {NodeHost, Host} from '@angular-cli/ast-tools'; /** * Rewrites import module of dependent files when the file is moved. @@ -21,13 +22,14 @@ export class ModuleResolver { * then apply() method is called sequentially. * * @param changes {Change []} + * @param host {Host} * @return Promise after all apply() method of Change class is called * to all Change instances sequentially. */ - applySortedChangePromise(changes: Change[]): Promise { + applySortedChangePromise(changes: Change[], host: Host = NodeHost): Promise { return changes .sort((currentChange, nextChange) => nextChange.order - currentChange.order) - .reduce((newChange, change) => newChange.then(() => change.apply()), Promise.resolve()); + .reduce((newChange, change) => newChange.then(() => change.apply(host)), Promise.resolve()); } /** diff --git a/packages/ast-tools/src/ast-utils.spec.ts b/packages/ast-tools/src/ast-utils.spec.ts index 001ff9a9beab..17c0444e417b 100644 --- a/packages/ast-tools/src/ast-utils.spec.ts +++ b/packages/ast-tools/src/ast-utils.spec.ts @@ -3,7 +3,7 @@ import mockFs = require('mock-fs'); import ts = require('typescript'); import fs = require('fs'); -import {InsertChange, RemoveChange} from './change'; +import {InsertChange, NodeHost, RemoveChange} from './change'; import {insertAfterLastOccurrence, addDeclarationToModule} from './ast-utils'; import {findNodes} from './node'; import {it} from './spec-utils'; @@ -31,7 +31,7 @@ describe('ast-utils: findNodes', () => { it('finds no imports', () => { let editedFile = new RemoveChange(sourceFile, 0, `import * as myTest from 'tests' \n`); return editedFile - .apply() + .apply(NodeHost) .then(() => { let rootNode = getRootNode(sourceFile); let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); @@ -47,10 +47,10 @@ describe('ast-utils: findNodes', () => { // remove new line and add an inline import let editedFile = new RemoveChange(sourceFile, 32, '\n'); return editedFile - .apply() + .apply(NodeHost) .then(() => { let insert = new InsertChange(sourceFile, 32, `import {Routes} from '@angular/routes'`); - return insert.apply(); + return insert.apply(NodeHost); }) .then(() => { let rootNode = getRootNode(sourceFile); @@ -61,7 +61,7 @@ describe('ast-utils: findNodes', () => { it('finds two imports from new line separated declarations', () => { let editedFile = new InsertChange(sourceFile, 33, `import {Routes} from '@angular/routes'`); return editedFile - .apply() + .apply(NodeHost) .then(() => { let rootNode = getRootNode(sourceFile); let nodes = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); @@ -89,7 +89,7 @@ describe('ast-utils: insertAfterLastOccurrence', () => { let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); return insertAfterLastOccurrence(imports, `\nimport { Router } from '@angular/router';`, sourceFile, 0) - .apply() + .apply(NodeHost) .then(() => { return readFile(sourceFile, 'utf8'); }).then((content) => { @@ -106,12 +106,12 @@ describe('ast-utils: insertAfterLastOccurrence', () => { let content = `import { foo, bar } from 'fizz';`; let editedFile = new InsertChange(sourceFile, 0, content); return editedFile - .apply() + .apply(NodeHost) .then(() => { let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); return insertAfterLastOccurrence(imports, ', baz', sourceFile, 0, ts.SyntaxKind.Identifier) - .apply(); + .apply(NodeHost); }) .then(() => { return readFile(sourceFile, 'utf8'); @@ -122,12 +122,12 @@ describe('ast-utils: insertAfterLastOccurrence', () => { let content = `import * from 'foo' \n import { bar } from 'baz'`; let editedFile = new InsertChange(sourceFile, 0, content); return editedFile - .apply() + .apply(NodeHost) .then(() => { let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); return insertAfterLastOccurrence(imports, `\nimport Router from '@angular/router'`, sourceFile) - .apply(); + .apply(NodeHost); }) .then(() => { return readFile(sourceFile, 'utf8'); @@ -142,12 +142,12 @@ describe('ast-utils: insertAfterLastOccurrence', () => { let content = `import {} from 'foo'`; let editedFile = new InsertChange(sourceFile, 0, content); return editedFile - .apply() + .apply(NodeHost) .then(() => { let imports = getNodesOfKind(ts.SyntaxKind.ImportDeclaration, sourceFile); return insertAfterLastOccurrence(imports, ', bar', sourceFile, undefined, ts.SyntaxKind.Identifier) - .apply(); + .apply(NodeHost); }) .catch(() => { return readFile(sourceFile, 'utf8'); @@ -160,7 +160,7 @@ describe('ast-utils: insertAfterLastOccurrence', () => { ts.SyntaxKind.CloseBraceToken).pop().pos; return insertAfterLastOccurrence(imports, ' bar ', sourceFile, pos, ts.SyntaxKind.Identifier) - .apply(); + .apply(NodeHost); }) .then(() => { return readFile(sourceFile, 'utf8'); @@ -211,7 +211,7 @@ class Module {}` it('works with empty array', () => { return addDeclarationToModule('1.ts', 'MyClass', 'MyImportPath') - .then(change => change.apply()) + .then(change => change.apply(NodeHost)) .then(() => readFile('1.ts', 'utf-8')) .then(content => { expect(content).toEqual( @@ -229,7 +229,7 @@ class Module {}` it('works with array with declarations', () => { return addDeclarationToModule('2.ts', 'MyClass', 'MyImportPath') - .then(change => change.apply()) + .then(change => change.apply(NodeHost)) .then(() => readFile('2.ts', 'utf-8')) .then(content => { expect(content).toEqual( @@ -250,7 +250,7 @@ class Module {}` it('works without any declarations', () => { return addDeclarationToModule('3.ts', 'MyClass', 'MyImportPath') - .then(change => change.apply()) + .then(change => change.apply(NodeHost)) .then(() => readFile('3.ts', 'utf-8')) .then(content => { expect(content).toEqual( @@ -268,7 +268,7 @@ class Module {}` it('works without a declaration field', () => { return addDeclarationToModule('4.ts', 'MyClass', 'MyImportPath') - .then(change => change.apply()) + .then(change => change.apply(NodeHost)) .then(() => readFile('4.ts', 'utf-8')) .then(content => { expect(content).toEqual( diff --git a/packages/ast-tools/src/change.spec.ts b/packages/ast-tools/src/change.spec.ts index d4abb85d5aad..0bebb3230169 100644 --- a/packages/ast-tools/src/change.spec.ts +++ b/packages/ast-tools/src/change.spec.ts @@ -4,7 +4,7 @@ let mockFs = require('mock-fs'); import {it} from './spec-utils'; -import {InsertChange, RemoveChange, ReplaceChange} from './change'; +import {InsertChange, NodeHost, RemoveChange, ReplaceChange} from './change'; import fs = require('fs'); let path = require('path'); @@ -35,7 +35,7 @@ describe('Change', () => { it('adds text to the source code', () => { let changeInstance = new InsertChange(sourceFile, 6, ' world!'); return changeInstance - .apply() + .apply(NodeHost) .then(() => readFile(sourceFile, 'utf8')) .then(contents => { expect(contents).toEqual('hello world!'); @@ -47,7 +47,7 @@ describe('Change', () => { it('adds nothing in the source code if empty string is inserted', () => { let changeInstance = new InsertChange(sourceFile, 6, ''); return changeInstance - .apply() + .apply(NodeHost) .then(() => readFile(sourceFile, 'utf8')) .then(contents => { expect(contents).toEqual('hello'); @@ -61,7 +61,7 @@ describe('Change', () => { it('removes given text from the source code', () => { let changeInstance = new RemoveChange(sourceFile, 9, 'as foo'); return changeInstance - .apply() + .apply(NodeHost) .then(() => readFile(sourceFile, 'utf8')) .then(contents => { expect(contents).toEqual('import * from "./bar"'); @@ -73,7 +73,7 @@ describe('Change', () => { it('does not change the file if told to remove empty string', () => { let changeInstance = new RemoveChange(sourceFile, 9, ''); return changeInstance - .apply() + .apply(NodeHost) .then(() => readFile(sourceFile, 'utf8')) .then(contents => { expect(contents).toEqual('import * as foo from "./bar"'); @@ -86,7 +86,7 @@ describe('Change', () => { let sourceFile = path.join(sourcePath, 'remove-replace-file.txt'); let changeInstance = new ReplaceChange(sourceFile, 7, '* as foo', '{ fooComponent }'); return changeInstance - .apply() + .apply(NodeHost) .then(() => readFile(sourceFile, 'utf8')) .then(contents => { expect(contents).toEqual('import { fooComponent } from "./bar"'); @@ -96,11 +96,22 @@ describe('Change', () => { let sourceFile = path.join(sourcePath, 'remove-replace-file.txt'); expect(() => new ReplaceChange(sourceFile, -6, 'hello', ' world!')).toThrow(); }); + it('fails for invalid replacement', () => { + let sourceFile = path.join(sourcePath, 'replace-file.txt'); + let changeInstance = new ReplaceChange(sourceFile, 0, 'foobar', ''); + return changeInstance + .apply(NodeHost) + .then(() => expect(false).toBe(true), err => { + // Check that the message contains the string to replace and the string from the file. + expect(err.message).toContain('foobar'); + expect(err.message).toContain('import'); + }); + }); it('adds string to the position of an empty string', () => { let sourceFile = path.join(sourcePath, 'replace-file.txt'); let changeInstance = new ReplaceChange(sourceFile, 9, '', 'BarComponent, '); return changeInstance - .apply() + .apply(NodeHost) .then(() => readFile(sourceFile, 'utf8')) .then(contents => { expect(contents).toEqual('import { BarComponent, FooComponent } from "./baz"'); @@ -108,9 +119,9 @@ describe('Change', () => { }); it('removes the given string only if an empty string to add is given', () => { let sourceFile = path.join(sourcePath, 'remove-replace-file.txt'); - let changeInstance = new ReplaceChange(sourceFile, 9, ' as foo', ''); + let changeInstance = new ReplaceChange(sourceFile, 8, ' as foo', ''); return changeInstance - .apply() + .apply(NodeHost) .then(() => readFile(sourceFile, 'utf8')) .then(contents => { expect(contents).toEqual('import * from "./bar"'); diff --git a/packages/ast-tools/src/change.ts b/packages/ast-tools/src/change.ts index dde0bc5523a4..14e537575b3c 100644 --- a/packages/ast-tools/src/change.ts +++ b/packages/ast-tools/src/change.ts @@ -4,8 +4,19 @@ import denodeify = require('denodeify'); const readFile = (denodeify(fs.readFile) as (...args: any[]) => Promise); const writeFile = (denodeify(fs.writeFile) as (...args: any[]) => Promise); +export interface Host { + write(path: string, content: string): Promise; + read(path: string): Promise; +} + +export const NodeHost: Host = { + write: (path: string, content: string) => writeFile(path, content, 'utf8'), + read: (path: string) => readFile(path, 'utf8') +}; + + export interface Change { - apply(): Promise; + apply(host: Host): Promise; // The file this change should be applied to. Some changes might not apply to // a file (maybe the config). @@ -61,11 +72,11 @@ export class MultiChange implements Change { get order() { return Math.max(...this._changes.map(c => c.order)); } get path() { return this._path; } - apply() { + apply(host: Host) { return this._changes .sort((a: Change, b: Change) => b.order - a.order) .reduce((promise, change) => { - return promise.then(() => change.apply()); + return promise.then(() => change.apply(host)); }, Promise.resolve()); } } @@ -90,11 +101,11 @@ export class InsertChange implements Change { /** * This method does not insert spaces if there is none in the original string. */ - apply(): Promise { - return readFile(this.path, 'utf8').then(content => { + apply(host: Host): Promise { + return host.read(this.path).then(content => { let prefix = content.substring(0, this.pos); let suffix = content.substring(this.pos); - return writeFile(this.path, `${prefix}${this.toAdd}${suffix}`); + return host.write(this.path, `${prefix}${this.toAdd}${suffix}`); }); } } @@ -115,12 +126,12 @@ export class RemoveChange implements Change { this.order = pos; } - apply(): Promise { - return readFile(this.path, 'utf8').then(content => { + apply(host: Host): Promise { + return host.read(this.path).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}`); + return host.write(this.path, `${prefix}${suffix}`); }); } } @@ -141,12 +152,17 @@ export class ReplaceChange implements Change { 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); + apply(host: Host): Promise { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos + this.oldText.length); + const text = content.substring(this.pos, this.pos + this.oldText.length); + + if (text !== this.oldText) { + return Promise.reject(new Error(`Invalid replace: "${text}" != "${this.oldText}".`)); + } // TODO: throw error if oldText doesn't match removed string. - return writeFile(this.path, `${prefix}${this.newText}${suffix}`); + return host.write(this.path, `${prefix}${this.newText}${suffix}`); }); } } diff --git a/packages/ast-tools/src/route-utils.spec.ts b/packages/ast-tools/src/route-utils.spec.ts index f1af932454e6..4405742a35f4 100644 --- a/packages/ast-tools/src/route-utils.spec.ts +++ b/packages/ast-tools/src/route-utils.spec.ts @@ -2,7 +2,7 @@ import * as mockFs from 'mock-fs'; import * as fs from 'fs'; import * as nru from './route-utils'; import * as path from 'path'; -import { InsertChange, RemoveChange } from './change'; +import { NodeHost, InsertChange, RemoveChange } from './change'; import denodeify = require('denodeify'); import * as _ from 'lodash'; import {it} from './spec-utils'; @@ -29,8 +29,8 @@ describe('route utils', () => { it('inserts as last import if not present', () => { let content = `'use strict'\n import {foo} from 'bar'\n import * as fz from 'fizz';`; let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile.apply() - .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply()) + return editedFile.apply(NodeHost) + .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply(NodeHost)) .then(() => readFile(sourceFile, 'utf8')) .then(newContent => { expect(newContent).toEqual(content + `\nimport { Router } from '@angular/router';`); @@ -39,7 +39,7 @@ describe('route utils', () => { 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() + return editedFile.apply(NodeHost) .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router')) .then(() => readFile(sourceFile, 'utf8')) .then(newContent => { @@ -49,8 +49,8 @@ describe('route utils', () => { 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()) + return editedFile.apply(NodeHost) + .then(() => nru.insertImport(sourceFile, 'baz', 'fizz').apply(NodeHost)) .then(() => readFile(sourceFile, 'utf8')) .then(newContent => { expect(newContent).toEqual(`'use strict'\n import { foo, bar, baz } from 'fizz'`); @@ -59,7 +59,7 @@ describe('route utils', () => { it('understands * imports', () => { let content = `\nimport * as myTest from 'tests' \n`; let editedFile = new InsertChange(sourceFile, 0, content); - return editedFile.apply() + return editedFile.apply(NodeHost) .then(() => nru.insertImport(sourceFile, 'Test', 'tests')) .then(() => readFile(sourceFile, 'utf8')) .then(newContent => { @@ -69,8 +69,8 @@ describe('route utils', () => { 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()) + return editedFile.apply(NodeHost) + .then(() => nru.insertImport(sourceFile, 'Router', '@angular/router').apply(NodeHost)) .then(() => readFile(sourceFile, 'utf8')) .then(newContent => { expect(newContent).toEqual( @@ -78,7 +78,7 @@ describe('route utils', () => { }); }); it('inserts inserts at beginning of file if no imports exist', () => { - return nru.insertImport(sourceFile, 'Router', '@angular/router').apply() + return nru.insertImport(sourceFile, 'Router', '@angular/router').apply(NodeHost) .then(() => readFile(sourceFile, 'utf8')) .then(newContent => { expect(newContent).toEqual(`import { Router } from '@angular/router';\n`); @@ -86,7 +86,7 @@ describe('route utils', () => { }); it('inserts subcomponent in win32 environment', () => { let content = './level1\\level2/level2.component'; - return nru.insertImport(sourceFile, 'level2', content).apply() + return nru.insertImport(sourceFile, 'level2', content).apply(NodeHost) .then(() => readFile(sourceFile, 'utf8')) .then(newContent => { if (process.platform.startsWith('win')) { @@ -132,7 +132,7 @@ describe('route utils', () => { }); }); xit('does not add a provideRouter import if it exits already', () => { - return nru.insertImport(mainFile, 'provideRouter', '@angular/router').apply() + return nru.insertImport(mainFile, 'provideRouter', '@angular/router').apply(NodeHost) .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) .then(() => readFile(mainFile, 'utf8')) .then(content => { @@ -145,7 +145,7 @@ describe('route utils', () => { xit('does not duplicate import to route.ts ', () => { let editedFile = new InsertChange(mainFile, 100, `\nimport routes from './routes';`); return editedFile - .apply() + .apply(NodeHost) .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) .then(() => readFile(mainFile, 'utf8')) .then(content => { @@ -163,7 +163,7 @@ describe('route utils', () => { }); it('adds provideRouter to bootstrap if absent and empty providers array', () => { let editFile = new InsertChange(mainFile, 124, ', []'); - return editFile.apply() + return editFile.apply(NodeHost) .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) .then(() => readFile(mainFile, 'utf8')) .then(content => { @@ -173,7 +173,7 @@ describe('route utils', () => { }); it('adds provideRouter to bootstrap if absent and non-empty providers array', () => { let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS ]'); - return editedFile.apply() + return editedFile.apply(NodeHost) .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) .then(() => readFile(mainFile, 'utf8')) .then(content => { @@ -185,7 +185,7 @@ describe('route utils', () => { let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS, provideRouter(routes) ]'); - return editedFile.apply() + return editedFile.apply(NodeHost) .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) .then(() => readFile(mainFile, 'utf8')) .then(content => { @@ -195,7 +195,7 @@ describe('route utils', () => { }); it('inserts into the correct array', () => { let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS, {provide: [BAR]}]'); - return editedFile.apply() + return editedFile.apply(NodeHost) .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) .then(() => readFile(mainFile, 'utf8')) .then(content => { @@ -205,7 +205,7 @@ describe('route utils', () => { }); it('throws an error if there is no or multiple bootstrap expressions', () => { let editedFile = new InsertChange(mainFile, 126, '\n bootstrap(moreStuff);'); - return editedFile.apply() + return editedFile.apply(NodeHost) .then(() => nru.bootstrapItem(mainFile, routes, toBootstrap)) .catch(e => expect(e.message).toEqual('Did not bootstrap provideRouter in' + @@ -214,7 +214,7 @@ describe('route utils', () => { }); 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() + return editedFile.apply(NodeHost) .then(() => nru.applyChanges(nru.bootstrapItem(mainFile, routes, toBootstrap))) .then(() => readFile(mainFile, 'utf8')) .then(content => { @@ -262,7 +262,7 @@ 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 editedFile.apply(NodeHost).then(() => { return nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options)); }).catch(e => { expect(e.message).toEqual('Did not insert path in routes.ts because ' @@ -271,7 +271,7 @@ export default [\n { path: 'new-route', component: NewRouteComponent }\n];`); }); it('throws error if no export defaults exists', () => { let editedFile = new RemoveChange(routesFile, 0, 'export default []'); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { return nru.addPathToRoutes(routesFile, _.merge({route: 'new-route'}, options)); }).catch(e => { expect(e.message).toEqual('Did not insert path in routes.ts because ' @@ -281,7 +281,7 @@ export default [\n { path: 'new-route', component: NewRouteComponent }\n];`); it('treats positional params correctly', () => { let editedFile = new InsertChange(routesFile, 16, `\n { path: 'home', component: HomeComponent }\n`); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'about'; options.component = 'AboutComponent'; return nru.applyChanges( @@ -299,7 +299,7 @@ export default [\n { path: 'new-route', component: NewRouteComponent }\n];`); }); it('inserts under parent, mid', () => { let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'details'; options.component = 'DetailsComponent'; return nru.applyChanges( @@ -324,7 +324,7 @@ export default [ }); it('inserts under parent, deep', () => { let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'sections'; options.component = 'SectionsComponent'; return nru.applyChanges( @@ -360,7 +360,7 @@ export default [ ] }\n`; let editedFile = new InsertChange(routesFile, 16, paths); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'about'; options.component = 'AboutComponent_1'; return nru.applyChanges( @@ -383,7 +383,7 @@ export default [ }); it('throws error if repeating child, shallow', () => { let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'home'; options.component = 'HomeComponent'; return nru.addPathToRoutes(routesFile, _.merge({route: '/home'}, options)); @@ -393,7 +393,7 @@ export default [ }); it('throws error if repeating child, mid', () => { let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'about'; options.component = 'AboutComponent'; return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/'}, options)); @@ -403,7 +403,7 @@ export default [ }); it('throws error if repeating child, deep', () => { let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'more'; options.component = 'MoreComponent'; return nru.addPathToRoutes(routesFile, _.merge({route: 'home/about/more'}, options)); @@ -413,7 +413,7 @@ export default [ }); it('does not report false repeat', () => { let editedFile = new InsertChange(routesFile, 16, nestedRoutes); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'more'; options.component = 'MoreComponent'; return nru.applyChanges(nru.addPathToRoutes(routesFile, _.merge({route: 'more'}, options))); @@ -448,7 +448,7 @@ export default [ },\n { path: 'trap-queen', component: TrapQueenComponent}\n`; let editedFile = new InsertChange(routesFile, 16, routes); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'trap-queen'; options.component = 'TrapQueenComponent'; return nru.applyChanges( @@ -475,10 +475,10 @@ export default [ it('resolves imports correctly', () => { let editedFile = new InsertChange(routesFile, 16, `\n { path: 'home', component: HomeComponent }\n`); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { let editedFile = new InsertChange(routesFile, 0, `import { HomeComponent } from './app/home/home.component';\n`); - return editedFile.apply(); + return editedFile.apply(NodeHost); }) .then(() => { options.dasherizedName = 'home'; @@ -507,12 +507,12 @@ export default [ { path: 'details', component: DetailsComponent } ] }`); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { let editedFile = new InsertChange(routesFile, 0, `import { AboutComponent } from './app/about/about.component'; import { DetailsComponent } from './app/about/details/details.component'; import { DetailsComponent as DetailsComponent_1 } from './app/about/description/details.component;\n`); // tslint:disable-line - return editedFile.apply(); + return editedFile.apply(NodeHost); }).then(() => { options.dasherizedName = 'details'; options.component = 'DetailsComponent'; @@ -524,7 +524,7 @@ import { DetailsComponent as DetailsComponent_1 } from './app/about/description/ it('adds guard to parent route: addItemsToRouteProperties', () => { let path = `\n { path: 'home', component: HomeComponent }\n`; let editedFile = new InsertChange(routesFile, 16, path); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { let toInsert = {'home': ['canActivate', '[ MyGuard ]'] }; return nru.applyChanges(nru.addItemsToRouteProperties(routesFile, toInsert)); }) @@ -539,7 +539,7 @@ import { DetailsComponent as DetailsComponent_1 } from './app/about/description/ it('adds guard to child route: addItemsToRouteProperties', () => { let path = `\n { path: 'home', component: HomeComponent }\n`; let editedFile = new InsertChange(routesFile, 16, path); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { options.dasherizedName = 'more'; options.component = 'MoreComponent'; return nru.applyChanges( @@ -609,14 +609,14 @@ export default [ }); it('finds component in the presence of decorators: confirmComponentExport', () => { let editedFile = new InsertChange(componentFile, 0, '@Component{}\n'); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent'); expect(exportExists).toBeTruthy(); }); }); it('report absence of component name: confirmComponentExport', () => { let editedFile = new RemoveChange(componentFile, 21, 'onent'); - return editedFile.apply().then(() => { + return editedFile.apply(NodeHost).then(() => { let exportExists = nru.confirmComponentExport(componentFile, 'AboutComponent'); expect(exportExists).not.toBeTruthy(); }); diff --git a/packages/ast-tools/src/route-utils.ts b/packages/ast-tools/src/route-utils.ts index 3fd166b0edf8..4455bafdc814 100644 --- a/packages/ast-tools/src/route-utils.ts +++ b/packages/ast-tools/src/route-utils.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import {Change, InsertChange, NoopChange} from './change'; import {findNodes} from './node'; import {insertAfterLastOccurrence} from './ast-utils'; +import {NodeHost, Host} from './change'; /** * Adds imports to mainFile and adds toBootstrap to the array of providers @@ -368,13 +369,14 @@ export function resolveComponentPath(projectRoot: string, currentDir: string, fi /** * Sort changes in decreasing order and apply them. * @param changes + * @param host * @return Promise */ -export function applyChanges(changes: Change[]): Promise { +export function applyChanges(changes: Change[], host: Host = NodeHost): Promise { return changes .filter(change => !!change) .sort((curr, next) => next.order - curr.order) - .reduce((newChange, change) => newChange.then(() => change.apply()), Promise.resolve()); + .reduce((newChange, change) => newChange.then(() => change.apply(host)), Promise.resolve()); } /** * Helper for addPathToRoutes. Adds child array to the appropriate position in the routes.ts file diff --git a/packages/webpack/package.json b/packages/webpack/package.json new file mode 100644 index 000000000000..90218f2ab6e0 --- /dev/null +++ b/packages/webpack/package.json @@ -0,0 +1,15 @@ +{ + "name": "@ngtools/webpack", + "version": "1.0.0", + "description": "", + "main": "./src/index.js", + "license": "MIT", + "dependencies": { + "@angular-cli/ast-tools": "^1.0.0" + }, + "peerDependencies": { + "typescript": "2.0.2", + "@angular/compiler-cli": "^0.6.0", + "@angular/core": "^2.0.0" + } +} diff --git a/packages/webpack/src/compiler.ts b/packages/webpack/src/compiler.ts new file mode 100644 index 000000000000..87dd33414619 --- /dev/null +++ b/packages/webpack/src/compiler.ts @@ -0,0 +1,16 @@ +import * as tscWrapped from '@angular/tsc-wrapped/src/compiler_host'; +import * as ts from 'typescript'; + + +export class NgcWebpackCompilerHost extends tscWrapped.DelegatingHost { + fileCache = new Map(); + + constructor(delegate: ts.CompilerHost) { + super(delegate); + } +} + +export function createCompilerHost(tsConfig: any) { + const delegateHost = ts.createCompilerHost(tsConfig['compilerOptions']); + return new NgcWebpackCompilerHost(delegateHost); +} diff --git a/packages/webpack/src/index.ts b/packages/webpack/src/index.ts new file mode 100644 index 000000000000..8fd3bad21818 --- /dev/null +++ b/packages/webpack/src/index.ts @@ -0,0 +1,4 @@ +import 'reflect-metadata'; + +export * from './plugin' +export {ngcLoader as default} from './loader' diff --git a/packages/webpack/src/loader.ts b/packages/webpack/src/loader.ts new file mode 100644 index 000000000000..ba162a5d18fd --- /dev/null +++ b/packages/webpack/src/loader.ts @@ -0,0 +1,149 @@ +import * as path from 'path'; +import * as ts from 'typescript'; +import {NgcWebpackPlugin} from './plugin'; +import {MultiChange, ReplaceChange, insertImport} from '@angular-cli/ast-tools'; + +// TODO: move all this to ast-tools. +function _findNodes(sourceFile: ts.SourceFile, node: ts.Node, kind: ts.SyntaxKind, + keepGoing = false): ts.Node[] { + if (node.kind == kind && !keepGoing) { + return [node]; + } + + return node.getChildren(sourceFile).reduce((result, n) => { + return result.concat(_findNodes(sourceFile, n, kind, keepGoing)); + }, node.kind == kind ? [node] : []); +} + +function _removeDecorators(fileName: string, source: string): string { + const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest); + // Find all decorators. + const decorators = _findNodes(sourceFile, sourceFile, ts.SyntaxKind.Decorator); + decorators.sort((a, b) => b.pos - a.pos); + + decorators.forEach(d => { + source = source.slice(0, d.pos) + source.slice(d.end); + }); + + return source; +} + + +function _replaceBootstrap(fileName: string, + source: string, + plugin: NgcWebpackPlugin): Promise { + // If bootstrapModule can't be found, bail out early. + if (!source.match(/\bbootstrapModule\b/)) { + return Promise.resolve(source); + } + + let changes = new MultiChange(); + + // Calculate the base path. + const basePath = path.normalize(plugin.angularCompilerOptions.basePath); + const genDir = path.normalize(plugin.genDir); + const dirName = path.normalize(path.dirname(fileName)); + const [entryModulePath, entryModuleName] = plugin.entryModule.split('#'); + const entryModuleFileName = path.normalize(entryModulePath + '.ngfactory'); + const relativeEntryModulePath = path.relative(basePath, entryModuleFileName); + const fullEntryModulePath = path.resolve(genDir, relativeEntryModulePath); + const relativeNgFactoryPath = path.relative(dirName, fullEntryModulePath); + const ngFactoryPath = './' + relativeNgFactoryPath.replace(/\\/g, '/'); + + const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest); + + const allCalls = _findNodes( + sourceFile, sourceFile, ts.SyntaxKind.CallExpression, true) as ts.CallExpression[]; + + const bootstraps = allCalls + .filter(call => call.expression.kind == ts.SyntaxKind.PropertyAccessExpression) + .map(call => call.expression as ts.PropertyAccessExpression) + .filter(access => { + return access.name.kind == ts.SyntaxKind.Identifier + && access.name.text == 'bootstrapModule'; + }); + + const calls: ts.Node[] = bootstraps + .reduce((previous, access) => { + return previous.concat(_findNodes(sourceFile, access, ts.SyntaxKind.CallExpression, true)); + }, []) + .filter(call => { + return call.expression.kind == ts.SyntaxKind.Identifier + && call.expression.text == 'platformBrowserDynamic'; + }); + + if (calls.length == 0) { + // Didn't find any dynamic bootstrapping going on. + return Promise.resolve(source); + } + + // Create the changes we need. + allCalls + .filter(call => bootstraps.some(bs => bs == call.expression)) + .forEach((call: ts.CallExpression) => { + changes.appendChange(new ReplaceChange(fileName, call.arguments[0].getStart(sourceFile), + entryModuleName, entryModuleName + 'NgFactory')); + }); + + calls + .forEach(call => { + changes.appendChange(new ReplaceChange(fileName, call.getStart(sourceFile), + 'platformBrowserDynamic', 'platformBrowser')); + }); + + bootstraps + .forEach((bs: ts.PropertyAccessExpression) => { + // This changes the call. + changes.appendChange(new ReplaceChange(fileName, bs.name.getStart(sourceFile), + 'bootstrapModule', 'bootstrapModuleFactory')); + }); + changes.appendChange(insertImport(fileName, 'platformBrowser', '@angular/platform-browser')); + changes.appendChange(insertImport(fileName, entryModuleName + 'NgFactory', ngFactoryPath)); + + let sourceText = source; + return changes.apply({ + read: (path: string) => Promise.resolve(sourceText), + write: (path: string, content: string) => Promise.resolve(sourceText = content) + }).then(() => sourceText); +} + + +// Super simple TS transpiler loader for testing / isolated usage. does not type check! +export function ngcLoader(source: string) { + this.cacheable(); + + const plugin = this._compilation._ngToolsWebpackPluginInstance as NgcWebpackPlugin; + if (plugin && plugin instanceof NgcWebpackPlugin) { + const cb: any = this.async(); + + plugin.done + .then(() => _removeDecorators(this.resource, source)) + .then(sourceText => _replaceBootstrap(this.resource, sourceText, plugin)) + .then(sourceText => { + const result = ts.transpileModule(sourceText, { + compilerOptions: { + target: ts.ScriptTarget.ES5, + module: ts.ModuleKind.ES2015, + } + }); + + if (result.diagnostics && result.diagnostics.length) { + let message = ''; + result.diagnostics.forEach(d => { + message += d.messageText + '\n'; + }); + cb(new Error(message)); + } + + cb(null, result.outputText, result.sourceMapText ? JSON.parse(result.sourceMapText) : null); + }) + .catch(err => cb(err)); + } else { + return ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES5, + module: ts.ModuleKind.ES2015, + } + }).outputText; + } +} diff --git a/packages/webpack/src/plugin.ts b/packages/webpack/src/plugin.ts new file mode 100644 index 000000000000..707c069c9ffe --- /dev/null +++ b/packages/webpack/src/plugin.ts @@ -0,0 +1,225 @@ +import * as ts from 'typescript'; +import * as path from 'path'; + +import {NgModule} from '@angular/core'; +import * as ngCompiler from '@angular/compiler-cli'; +import {tsc} from '@angular/tsc-wrapped/src/tsc'; + +import {patchReflectorHost} from './reflector_host'; +import {WebpackResourceLoader} from './resource_loader'; +import {createResolveDependenciesFromContextMap} from './utils'; +import { AngularCompilerOptions } from '@angular/tsc-wrapped'; + + +/** + * Option Constants + */ +export interface AngularWebpackPluginOptions { + tsconfigPath?: string; + providers?: any[]; + entryModule?: string; + project: string; + baseDir: string; + basePath?: string; + genDir?: string; +} + + +export class NgcWebpackPlugin { + projectPath: string; + rootModule: string; + rootModuleName: string; + reflector: ngCompiler.StaticReflector; + reflectorHost: ngCompiler.ReflectorHost; + program: ts.Program; + compilerHost: ts.CompilerHost; + compilerOptions: ts.CompilerOptions; + angularCompilerOptions: AngularCompilerOptions; + files: any[]; + lazyRoutes: any; + loader: any; + genDir: string; + entryModule: string; + + done: Promise; + + nmf: any = null; + cmf: any = null; + compiler: any = null; + compilation: any = null; + + constructor(public options: AngularWebpackPluginOptions) { + const tsConfig = tsc.readConfiguration(options.project, options.baseDir); + this.compilerOptions = tsConfig.parsed.options; + this.files = tsConfig.parsed.fileNames; + this.angularCompilerOptions = Object.assign({}, tsConfig.ngOptions, options); + + this.angularCompilerOptions.basePath = options.baseDir || process.cwd(); + this.genDir = this.options.genDir + || path.resolve(process.cwd(), this.angularCompilerOptions.genDir + '/app'); + this.entryModule = options.entryModule || (this.angularCompilerOptions as any).entryModule; + + const entryModule = this.entryModule; + const [rootModule, rootNgModule] = entryModule.split('#'); + this.projectPath = options.project; + this.rootModule = rootModule; + this.rootModuleName = rootNgModule; + this.compilerHost = ts.createCompilerHost(this.compilerOptions, true); + this.program = ts.createProgram(this.files, this.compilerOptions, this.compilerHost); + this.reflectorHost = new ngCompiler.ReflectorHost( + this.program, this.compilerHost, this.angularCompilerOptions); + this.reflector = new ngCompiler.StaticReflector(this.reflectorHost); + } + + // registration hook for webpack plugin + apply(compiler: any) { + this.compiler = compiler; + compiler.plugin('normal-module-factory', (nmf: any) => this.nmf = nmf); + compiler.plugin('context-module-factory', (cmf: any) => { + this.cmf = cmf; + cmf.plugin('before-resolve', (request: any, callback: (err?: any, request?: any) => void) => { + if (!request) { + return callback(); + } + + request.request = this.genDir; + request.recursive = true; + request.dependencies.forEach((d: any) => d.critical = false); + return callback(null, request); + }); + cmf.plugin('after-resolve', (result: any, callback: (err?: any, request?: any) => void) => { + if (!result) { + return callback(); + } + + result.resource = this.genDir; + result.recursive = true; + result.dependencies.forEach((d: any) => d.critical = false); + result.resolveDependencies = createResolveDependenciesFromContextMap((_: any, cb: any) => { + return cb(null, this.lazyRoutes); + }); + + return callback(null, result); + }); + }); + + compiler.plugin('make', (compilation: any, cb: any) => this._make(compilation, cb)); + compiler.plugin('after-emit', (compilation: any, cb: any) => { + this.done = null; + this.compilation = null; + compilation._ngToolsWebpackPluginInstance = null; + cb(); + }); + } + + private _make(compilation: any, cb: (err?: any, request?: any) => void) { + const rootModulePath = path.normalize(this.rootModule + '.ts'); + const rootModuleName = this.rootModuleName; + this.compilation = compilation; + + if (this.compilation._ngToolsWebpackPluginInstance) { + cb(new Error('A ngtools/webpack plugin already exist for this compilation.')); + } + this.compilation._ngToolsWebpackPluginInstance = this; + + this.loader = new WebpackResourceLoader(compilation); + + const i18nOptions: any = { + i18nFile: undefined, + i18nFormat: undefined, + locale: undefined, + basePath: this.options.baseDir + }; + + // Create the Code Generator. + const codeGenerator = ngCompiler.CodeGenerator.create( + this.angularCompilerOptions, + i18nOptions, + this.program, + this.compilerHost, + new ngCompiler.NodeReflectorHostContext(this.compilerHost), + this.loader + ); + + // We need to temporarily patch the CodeGenerator until either it's patched or allows us + // to pass in our own ReflectorHost. + patchReflectorHost(codeGenerator); + this.done = codeGenerator.codegen() + .then(() => { + // process the lazy routes + const lazyModules = this._processNgModule(rootModulePath, rootModuleName, rootModulePath) + .map(moduleKey => moduleKey.split('#')[0]); + this.lazyRoutes = lazyModules.reduce((lazyRoutes: any, lazyModule: any) => { + const genDir = this.genDir; + lazyRoutes[`${lazyModule}.ngfactory`] = path.join(genDir, lazyModule + '.ngfactory.ts'); + return lazyRoutes; + }, {}); + }) + .then(() => cb(), (err) => cb(err)); + } + + private _processNgModule(mod: string, ngModuleName: string, containingFile: string): string[] { + const staticSymbol = this.reflectorHost.findDeclaration(mod, ngModuleName, containingFile); + const entryNgModuleMetadata = this.getNgModuleMetadata(staticSymbol); + const loadChildren = this.extractLoadChildren(entryNgModuleMetadata); + + return loadChildren.reduce((res, lc) => { + const [childModule, childNgModule] = lc.split('#'); + + // TODO calculate a different containingFile for relative paths + + const children = this._processNgModule(childModule, childNgModule, containingFile); + return res.concat(children); + }, loadChildren); + } + + private getNgModuleMetadata(staticSymbol: ngCompiler.StaticSymbol) { + const ngModules = this.reflector.annotations(staticSymbol).filter(s => s instanceof NgModule); + if (ngModules.length === 0) { + throw new Error(`${staticSymbol.name} is not an NgModule`); + } + return ngModules[0]; + } + + private extractLoadChildren(ngModuleDecorator: any): any[] { + const routes = ngModuleDecorator.imports.reduce((mem: any[], m: any) => { + return mem.concat(this.collectRoutes(m.providers)); + }, this.collectRoutes(ngModuleDecorator.providers)); + return this.collectLoadChildren(routes); + } + + private collectRoutes(providers: any[]): any[] { + if (!providers) { + return []; + } + const ROUTES = this.reflectorHost.findDeclaration( + '@angular/router/src/router_config_loader', 'ROUTES', undefined); + + return providers.reduce((m, p) => { + if (p.provide === ROUTES) { + return m.concat(p.useValue); + } else if (Array.isArray(p)) { + return m.concat(this.collectRoutes(p)); + } else { + return m; + } + }, []); + } + + private collectLoadChildren(routes: any[]): any[] { + if (!routes) { + return []; + } + return routes.reduce((m, r) => { + if (r.loadChildren) { + return m.concat(r.loadChildren); + } else if (Array.isArray(r)) { + return m.concat(this.collectLoadChildren(r)); + } else if (r.children) { + return m.concat(this.collectLoadChildren(r.children)); + } else { + return m; + } + }, []); + } +} diff --git a/packages/webpack/src/reflector_host.ts b/packages/webpack/src/reflector_host.ts new file mode 100644 index 000000000000..0e995c2ee007 --- /dev/null +++ b/packages/webpack/src/reflector_host.ts @@ -0,0 +1,26 @@ +import {CodeGenerator} from '@angular/compiler-cli'; + + +/** + * Patch the CodeGenerator instance to use a custom reflector host. + */ +export function patchReflectorHost(codeGenerator: CodeGenerator) { + const reflectorHost = (codeGenerator as any).reflectorHost; + const oldGIP = reflectorHost.getImportPath; + + reflectorHost.getImportPath = function(containingFile: string, importedFile: string): string { + // Hack together SCSS and LESS files URLs so that they match what the default ReflectorHost + // is expected. We only do that for shimmed styles. + const m = importedFile.match(/(.*)(\..+)(\.shim)(\..+)/); + if (!m) { + return oldGIP.call(this, containingFile, importedFile); + } + + // We call the original, with `css` in its name instead of the extension, and replace the + // extension from the result. + const [, baseDirAndName, styleExt, shim, ext] = m; + const result = oldGIP.call(this, containingFile, baseDirAndName + '.css' + shim + ext); + + return result.replace(/\.css\./, styleExt + '.'); + }; +} diff --git a/packages/webpack/src/resource_loader.ts b/packages/webpack/src/resource_loader.ts new file mode 100644 index 000000000000..5b1d069b9967 --- /dev/null +++ b/packages/webpack/src/resource_loader.ts @@ -0,0 +1,116 @@ +import {ResourceLoader} from '@angular/compiler'; +import {readFileSync} from 'fs'; +import * as vm from 'vm'; +import * as path from 'path'; + +const NodeTemplatePlugin = require('webpack/lib/node/NodeTemplatePlugin'); +const NodeTargetPlugin = require('webpack/lib/node/NodeTargetPlugin'); +const LoaderTargetPlugin = require('webpack/lib/LoaderTargetPlugin'); +const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin'); + + + +export class WebpackResourceLoader implements ResourceLoader { + private _context: string; + private _uniqueId = 0; + + constructor(private _compilation: any) { + this._context = _compilation.context; + } + + private _compile(filePath: string, content: string): Promise { + const compilerName = `compiler(${this._uniqueId++})`; + const outputOptions = { filename: filePath }; + const relativePath = path.relative(this._context || '', filePath); + const childCompiler = this._compilation.createChildCompiler(relativePath, outputOptions); + childCompiler.context = this._context; + childCompiler.apply( + new NodeTemplatePlugin(outputOptions), + new NodeTargetPlugin(), + new SingleEntryPlugin(this._context, filePath, content), + new LoaderTargetPlugin('node') + ); + + // Store the result of the parent compilation before we start the child compilation + let assetsBeforeCompilation = Object.assign( + {}, + this._compilation.assets[outputOptions.filename] + ); + + // Fix for "Uncaught TypeError: __webpack_require__(...) is not a function" + // Hot module replacement requires that every child compiler has its own + // cache. @see https://github.com/ampedandwired/html-webpack-plugin/pull/179 + childCompiler.plugin('compilation', function (compilation: any) { + if (compilation.cache) { + if (!compilation.cache[compilerName]) { + compilation.cache[compilerName] = {}; + } + compilation.cache = compilation.cache[compilerName]; + } + }); + + // Compile and return a promise + return new Promise((resolve, reject) => { + childCompiler.runAsChild((err: Error, entries: any[], childCompilation: any) => { + // Resolve / reject the promise + if (childCompilation && childCompilation.errors && childCompilation.errors.length) { + const errorDetails = childCompilation.errors.map(function (error: any) { + return error.message + (error.error ? ':\n' + error.error : ''); + }).join('\n'); + reject(new Error('Child compilation failed:\n' + errorDetails)); + } else if (err) { + reject(err); + } else { + // Replace [hash] placeholders in filename + const outputName = this._compilation.mainTemplate.applyPluginsWaterfall( + 'asset-path', outputOptions.filename, { + hash: childCompilation.hash, + chunk: entries[0] + }); + + // Restore the parent compilation to the state like it was before the child compilation. + this._compilation.assets[outputName] = assetsBeforeCompilation[outputName]; + if (assetsBeforeCompilation[outputName] === undefined) { + // If it wasn't there - delete it. + delete this._compilation.assets[outputName]; + } + + resolve({ + // Hash of the template entry point. + hash: entries[0].hash, + // Output name. + outputName: outputName, + // Compiled code. + content: childCompilation.assets[outputName].source() + }); + } + }); + }); + } + + private _evaluate(fileName: string, source: string): Promise { + const vmContext = vm.createContext(Object.assign({ require: require }, global)); + const vmScript = new vm.Script(source, { filename: fileName }); + + // Evaluate code and cast to string + let newSource: string; + try { + newSource = vmScript.runInContext(vmContext).toString(); + } catch (e) { + return Promise.reject(e); + } + + if (typeof newSource == 'string') { + return Promise.resolve(newSource); + } else if (typeof newSource == 'function') { + return Promise.resolve(newSource()); + } + + return Promise.reject('The loader "' + fileName + '" didn\'t return a string.'); + } + + get(filePath: string): Promise { + return this._compile(filePath, readFileSync(filePath, 'utf8')) + .then((result: any) => this._evaluate(result.outputName, result.content)); + } +} diff --git a/packages/webpack/src/utils.ts b/packages/webpack/src/utils.ts new file mode 100644 index 000000000000..954cc206ad9b --- /dev/null +++ b/packages/webpack/src/utils.ts @@ -0,0 +1,16 @@ +const ContextElementDependency = require('webpack/lib/dependencies/ContextElementDependency'); + +export function createResolveDependenciesFromContextMap(createContextMap: Function) { + return (fs: any, resource: any, recursive: any, regExp: RegExp, callback: any) => { + createContextMap(fs, function(err: Error, map: any) { + if (err) { + return callback(err); + } + + const dependencies = Object.keys(map) + .map((key) => new ContextElementDependency(map[key], key)); + + callback(null, dependencies); + }); + }; +} diff --git a/packages/webpack/tsconfig.json b/packages/webpack/tsconfig.json new file mode 100644 index 000000000000..68fa995ac3a7 --- /dev/null +++ b/packages/webpack/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "declaration": true, + "experimentalDecorators": true, + "mapRoot": "", + "module": "commonjs", + "moduleResolution": "node", + "noEmitOnError": true, + "noImplicitAny": true, + "outDir": "../../dist/webpack", + "rootDir": ".", + "lib": ["es2015", "es6", "dom"], + "target": "es5", + "sourceMap": true, + "sourceRoot": "/", + "baseUrl": ".", + "paths": { + "@angular-cli/ast-tools": [ "../../dist/ast-tools/src" ] + }, + "typeRoots": [ + "../../node_modules/@types" + ], + "types": [ + "jasmine", + "node" + ] + } +} diff --git a/scripts/publish/build.js b/scripts/publish/build.js index b5bda9d3a951..642a353f6a1a 100755 --- a/scripts/publish/build.js +++ b/scripts/publish/build.js @@ -40,6 +40,18 @@ Promise.resolve() .then(() => { const packages = require('../../lib/packages'); return Object.keys(packages) + // Order packages in order of dependency. + .sort((a, b) => { + const aPackageJson = require(packages[a].packageJson); + const bPackageJson = require(packages[b].packageJson); + if (Object.keys(aPackageJson['dependencies'] || {}).indexOf(b) == -1) { + return 0; + } else if (Object.keys(bPackageJson['dependencies'] || {}).indexOf(a) == -1) { + return 1; + } else { + return -1; + } + }) .reduce((promise, packageName) => { const pkg = packages[packageName]; const name = path.relative(packagesRoot, pkg.root); diff --git a/tests/e2e/assets/webpack/test-app/app/app.component.html b/tests/e2e/assets/webpack/test-app/app/app.component.html new file mode 100644 index 000000000000..5a532db9308f --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/app/app.component.html @@ -0,0 +1,5 @@ +
+

hello world

+ lazy + +
diff --git a/tests/e2e/assets/webpack/test-app/app/app.component.scss b/tests/e2e/assets/webpack/test-app/app/app.component.scss new file mode 100644 index 000000000000..5cde7b922336 --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/app/app.component.scss @@ -0,0 +1,3 @@ +:host { + background-color: blue; +} diff --git a/tests/e2e/assets/webpack/test-app/app/app.component.ts b/tests/e2e/assets/webpack/test-app/app/app.component.ts new file mode 100644 index 000000000000..2fc4df46be32 --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/app/app.component.ts @@ -0,0 +1,10 @@ +// Code generated by angular2-stress-test + +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] +}) +export class AppComponent { } diff --git a/tests/e2e/assets/webpack/test-app/app/app.module.ts b/tests/e2e/assets/webpack/test-app/app/app.module.ts new file mode 100644 index 000000000000..79e133f3eeeb --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/app/app.module.ts @@ -0,0 +1,28 @@ +// Code generated by angular2-stress-test +import { NgModule, Component } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app.component'; + +@Component({ + selector: 'home-view', + template: 'home!' +}) +export class HomeView {} + + +@NgModule({ + declarations: [ + AppComponent, + HomeView + ], + imports: [ + BrowserModule, + RouterModule.forRoot([ + {path: 'lazy', loadChildren: './lazy.module#LazyModule'}, + {path: '', component: HomeView} + ]) + ], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/tests/e2e/assets/webpack/test-app/app/feature/feature.module.ts b/tests/e2e/assets/webpack/test-app/app/feature/feature.module.ts new file mode 100644 index 000000000000..f464ca028b05 --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/app/feature/feature.module.ts @@ -0,0 +1,20 @@ +import {NgModule, Component} from '@angular/core'; +import {RouterModule} from '@angular/router'; + +@Component({ + selector: 'feature-component', + template: 'foo.html' +}) +export class FeatureComponent {} + +@NgModule({ + declarations: [ + FeatureComponent + ], + imports: [ + RouterModule.forChild([ + { path: '', component: FeatureComponent} + ]) + ] +}) +export class FeatureModule {} diff --git a/tests/e2e/assets/webpack/test-app/app/lazy.module.ts b/tests/e2e/assets/webpack/test-app/app/lazy.module.ts new file mode 100644 index 000000000000..29dab5d11d35 --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/app/lazy.module.ts @@ -0,0 +1,25 @@ +import {NgModule, Component} from '@angular/core'; +import {RouterModule} from '@angular/router'; +import {HttpModule, Http} from '@angular/http'; + +@Component({ + selector: 'lazy-comp', + template: 'lazy!' +}) +export class LazyComponent {} + +@NgModule({ + imports: [ + RouterModule.forChild([ + {path: '', component: LazyComponent, pathMatch: 'full'}, + {path: 'feature', loadChildren: './feature/feature.module#FeatureModule'} + ]), + HttpModule + ], + declarations: [LazyComponent] +}) +export class LazyModule { + constructor(http: Http) {} +} + +export class SecondModule {} diff --git a/tests/e2e/assets/webpack/test-app/app/main.aot.ts b/tests/e2e/assets/webpack/test-app/app/main.aot.ts new file mode 100644 index 000000000000..1b4503a81e31 --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/app/main.aot.ts @@ -0,0 +1,5 @@ +import 'core-js/es7/reflect'; +import {platformBrowser} from '@angular/platform-browser'; +import {AppModuleNgFactory} from './ngfactory/app/app.module.ngfactory'; + +platformBrowser().bootstrapModuleFactory(AppModuleNgFactory); diff --git a/tests/e2e/assets/webpack/test-app/app/main.jit.ts b/tests/e2e/assets/webpack/test-app/app/main.jit.ts new file mode 100644 index 000000000000..4f083729991e --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/app/main.jit.ts @@ -0,0 +1,5 @@ +import 'reflect-metadata'; +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; +import {AppModule} from './app.module'; + +platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/tests/e2e/assets/webpack/test-app/index.html b/tests/e2e/assets/webpack/test-app/index.html new file mode 100644 index 000000000000..89fb0893c35d --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/index.html @@ -0,0 +1,12 @@ + + + + Document + + + + + + + + diff --git a/tests/e2e/assets/webpack/test-app/package.json b/tests/e2e/assets/webpack/test-app/package.json new file mode 100644 index 000000000000..a82238a71050 --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/package.json @@ -0,0 +1,26 @@ +{ + "name": "test", + "license": "MIT", + "dependencies": { + "@angular/common": "^2.0.0", + "@angular/compiler": "^2.0.0", + "@angular/compiler-cli": "0.6.2", + "@angular/core": "^2.0.0", + "@angular/http": "^2.0.0", + "@angular/platform-browser": "^2.0.0", + "@angular/platform-browser-dynamic": "^2.0.0", + "@angular/platform-server": "^2.0.0", + "@angular/router": "^3.0.0", + "core-js": "^2.4.1", + "rxjs": "^5.0.0-beta.12", + "zone.js": "^0.6.21" + }, + "devDependencies": { + "node-sass": "^3.7.0", + "performance-now": "^0.2.0", + "raw-loader": "^0.5.1", + "sass-loader": "^3.2.0", + "typescript": "2.0.2", + "webpack": "2.1.0-beta.22" + } +} diff --git a/tests/e2e/assets/webpack/test-app/tsconfig.json b/tests/e2e/assets/webpack/test-app/tsconfig.json new file mode 100644 index 000000000000..585586e38d21 --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "es2015", + "moduleResolution": "node", + "target": "es5", + "noImplicitAny": false, + "sourceMap": true, + "mapRoot": "", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": [ + "es2015", + "dom" + ], + "outDir": "lib", + "skipLibCheck": true, + "rootDir": "." + }, + "angularCompilerOptions": { + "genDir": "./app/ngfactory", + "entryModule": "app/app.module#AppModule" + } +} diff --git a/tests/e2e/assets/webpack/test-app/webpack.config.js b/tests/e2e/assets/webpack/test-app/webpack.config.js new file mode 100644 index 000000000000..cfb6ea75f284 --- /dev/null +++ b/tests/e2e/assets/webpack/test-app/webpack.config.js @@ -0,0 +1,31 @@ +const NgcWebpackPlugin = require('@ngtools/webpack').NgcWebpackPlugin; +const path = require('path'); + +module.exports = { + resolve: { + extensions: ['.ts', '.js'] + }, + entry: './app/main.aot.ts', + output: { + path: './dist', + publicPath: 'dist/', + filename: 'app.main.js' + }, + plugins: [ + new NgcWebpackPlugin({ + project: './tsconfig.json', + baseDir: path.resolve(__dirname, '') + }) + ], + module: { + loaders: [ + { test: /\.scss$/, loaders: ['raw-loader', 'sass-loader'] }, + { test: /\.css$/, loader: 'raw-loader' }, + { test: /\.html$/, loader: 'raw-loader' }, + { test: /\.ts$/, loader: '@ngtools/webpack' } + ] + }, + devServer: { + historyApiFallback: true + } +}; diff --git a/tests/e2e/setup/100-npm-link.ts b/tests/e2e/setup/100-npm-link.ts index fbe66d23b70c..396b974d94ec 100644 --- a/tests/e2e/setup/100-npm-link.ts +++ b/tests/e2e/setup/100-npm-link.ts @@ -1,5 +1,8 @@ import {join} from 'path'; import {npm, exec} from '../utils/process'; +import {updateJsonFile} from '../utils/project'; + +const packages = require('../../../lib/packages'); export default function (argv: any) { @@ -12,8 +15,24 @@ export default function (argv: any) { const distAngularCli = join(__dirname, '../../../dist/angular-cli'); const oldCwd = process.cwd(); process.chdir(distAngularCli); - return npm('link') - .then(() => process.chdir(oldCwd)); + + // Update the package.json of each packages. + return Promise.all(Object.keys(packages).map(pkgName => { + const p = packages[pkgName]; + + return updateJsonFile(join(p.dist, 'package.json'), json => { + for (const pkgName of Object.keys(packages)) { + if (json['dependencies'] && json['dependencies'][pkgName]) { + json['dependencies'][pkgName] = packages[pkgName].dist; + } + if (json['devDependencies'] && json['devDependencies'][pkgName]) { + json['devDependencies'][pkgName] = packages[pkgName].dist; + } + } + }); + })) + .then(() => npm('link')) + .then(() => process.chdir(oldCwd)); }) .then(() => exec(process.platform.startsWith('win') ? 'where' : 'which', 'ng')); } diff --git a/tests/e2e/setup/200-create-tmp-dir.ts b/tests/e2e/setup/200-create-tmp-dir.ts index e1a6dbfffc91..8cd88ec70a11 100644 --- a/tests/e2e/setup/200-create-tmp-dir.ts +++ b/tests/e2e/setup/200-create-tmp-dir.ts @@ -1,9 +1,12 @@ -const temp = require('temp'); +import {setGlobalVariable} from '../utils/env'; + +const temp = require('temp'); export default function(argv: any) { // Get to a temporary directory. let tempRoot = argv.reuse || temp.mkdirSync('angular-cli-e2e-'); console.log(` Using "${tempRoot}" as temporary directory for a new project.`); + setGlobalVariable('tmp-root', tempRoot); process.chdir(tempRoot); } diff --git a/tests/e2e/setup/300-log-environment.ts b/tests/e2e/setup/300-log-environment.ts new file mode 100644 index 000000000000..de8823beee8a --- /dev/null +++ b/tests/e2e/setup/300-log-environment.ts @@ -0,0 +1,21 @@ +import {node, ng, npm} from '../utils/process'; + +const packages = require('../../../lib/packages'); + + +export default function() { + return Promise.resolve() + .then(() => console.log('Environment:')) + .then(() => { + Object.keys(process.env).forEach(envName => { + console.log(` ${envName}: ${process.env[envName].replace(/[\n\r]+/g, '\n ')}`); + }); + }) + .then(() => { + console.log('Packages:'); + console.log(JSON.stringify(packages, null, 2)); + }) + .then(() => node('--version')) + .then(() => npm('--version')) + .then(() => ng('version')); +} diff --git a/tests/e2e/setup/500-create-project.ts b/tests/e2e/setup/500-create-project.ts index dd7fb0481a26..e2ff78992b76 100644 --- a/tests/e2e/setup/500-create-project.ts +++ b/tests/e2e/setup/500-create-project.ts @@ -9,8 +9,11 @@ import {gitClean, gitCommit} from '../utils/git'; export default function(argv: any) { let createProject = null; - // If we're set to reuse an existing project, just chdir to it and clean it. - if (argv.reuse) { + // This is a dangerous flag, but is useful for testing packages only. + if (argv.noproject) { + return Promise.resolve(); + } else if (argv.reuse) { + // If we're set to reuse an existing project, just chdir to it and clean it. createProject = Promise.resolve() .then(() => process.chdir(argv.reuse)) .then(() => gitClean()); @@ -29,6 +32,7 @@ export default function(argv: any) { json['devDependencies']['angular-cli'] = join(dist, 'angular-cli'); json['devDependencies']['@angular-cli/ast-tools'] = join(dist, 'ast-tools'); json['devDependencies']['@angular-cli/base-href-webpack'] = join(dist, 'base-href-webpack'); + json['devDependencies']['@ngtools/webpack'] = join(dist, 'webpack'); })) .then(() => { if (argv.nightly) { @@ -57,5 +61,6 @@ export default function(argv: any) { })) .then(() => git('config', 'user.email', 'angular-core+e2e@google.com')) .then(() => git('config', 'user.name', 'Angular CLI E2e')) + .then(() => git('config', 'commit.gpgSign', 'false')) .then(() => gitCommit('tsconfig-e2e-update')); } diff --git a/tests/e2e/tests/build/styles/less.ts b/tests/e2e/tests/build/styles/less.ts index 840d0bde2483..279742be2ceb 100644 --- a/tests/e2e/tests/build/styles/less.ts +++ b/tests/e2e/tests/build/styles/less.ts @@ -28,6 +28,6 @@ export default function() { .then(() => replaceInFile('src/app/app.component.ts', './app.component.css', './app.component.less')) .then(() => ng('build')) - .then(() => expectFileToMatch('dist/main.bundle.js', '.outer .inner')) + .then(() => expectFileToMatch('dist/main.bundle.js', /.outer.*.inner.*background:\s*#[fF]+/)) .then(() => moveFile('src/app/app.component.less', 'src/app/app.component.css')); } diff --git a/tests/e2e/tests/build/styles/scss.ts b/tests/e2e/tests/build/styles/scss.ts index 48b4792312a4..52d7d46fbd42 100644 --- a/tests/e2e/tests/build/styles/scss.ts +++ b/tests/e2e/tests/build/styles/scss.ts @@ -35,7 +35,7 @@ export default function() { .then(() => replaceInFile('src/app/app.component.ts', './app.component.css', './app.component.scss')) .then(() => ng('build')) - .then(() => expectFileToMatch('dist/main.bundle.js', '.outer .inner')) - .then(() => expectFileToMatch('dist/main.bundle.js', '.partial .inner')) + .then(() => expectFileToMatch('dist/main.bundle.js', /\.outer.*\.inner.*background.*#def/)) + .then(() => expectFileToMatch('dist/main.bundle.js', /\.partial.*\.inner.*background.*#def/)) .then(() => moveFile('src/app/app.component.scss', 'src/app/app.component.css')); } diff --git a/tests/e2e/tests/packages/webpack/test.ts b/tests/e2e/tests/packages/webpack/test.ts new file mode 100644 index 000000000000..26c848e97636 --- /dev/null +++ b/tests/e2e/tests/packages/webpack/test.ts @@ -0,0 +1,29 @@ +import {copyAssets} from '../../../utils/assets'; +import {exec, silentNpm} from '../../../utils/process'; +import {updateJsonFile} from '../../../utils/project'; +import {join} from 'path'; +import {expectFileSizeToBeUnder} from '../../../utils/fs'; + + +export default function(argv: any, skipCleaning: () => void) { + const currentDir = process.cwd(); + + if (process.platform.startsWith('win')) { + // Disable the test on Windows. + return Promise.resolve(); + } + + return Promise.resolve() + .then(() => copyAssets('webpack/test-app')) + .then(dir => process.chdir(dir)) + .then(() => updateJsonFile('package.json', json => { + const dist = '../../../../../dist/'; + json['dependencies']['@ngtools/webpack'] = join(__dirname, dist, 'webpack'); + })) + .then(() => silentNpm('install')) + .then(() => exec('node_modules/.bin/webpack', '-p')) + .then(() => expectFileSizeToBeUnder('dist/app.main.js', 400000)) + .then(() => expectFileSizeToBeUnder('dist/0.app.main.js', 40000)) + .then(() => process.chdir(currentDir)) + .then(() => skipCleaning()); +} diff --git a/tests/e2e/utils/assets.ts b/tests/e2e/utils/assets.ts new file mode 100644 index 000000000000..309d25702638 --- /dev/null +++ b/tests/e2e/utils/assets.ts @@ -0,0 +1,29 @@ +import {join} from 'path'; +import * as glob from 'glob'; +import {getGlobalVariable} from './env'; +import {relative} from 'path'; +import {copyFile} from './fs'; + + +export function assetDir(assetName: string) { + return join(__dirname, '../assets', assetName); +} + + +export function copyAssets(assetName: string) { + const tempRoot = join(getGlobalVariable('tmp-root'), 'assets', assetName); + const root = assetDir(assetName); + + return Promise.resolve() + .then(() => { + const allFiles = glob.sync(join(root, '**/*'), { dot: true, nodir: true }); + + return allFiles.reduce((promise, filePath) => { + const relPath = relative(root, filePath); + const toPath = join(tempRoot, relPath); + + return promise.then(() => copyFile(filePath, toPath)); + }, Promise.resolve()); + }) + .then(() => tempRoot); +} diff --git a/tests/e2e/utils/ast.ts b/tests/e2e/utils/ast.ts index d5a5376f9996..b23be570a8fd 100644 --- a/tests/e2e/utils/ast.ts +++ b/tests/e2e/utils/ast.ts @@ -1,15 +1,16 @@ import { insertImport as _insertImport, - addImportToModule as _addImportToModule + addImportToModule as _addImportToModule, + NodeHost } from '@angular-cli/ast-tools'; export function insertImport(file: string, symbol: string, module: string) { return _insertImport(file, symbol, module) - .then(change => change.apply()); + .then(change => change.apply(NodeHost)); } export function addImportToModule(file: string, symbol: string, module: string) { return _addImportToModule(file, symbol, module) - .then(change => change.apply()); + .then(change => change.apply(NodeHost)); } diff --git a/tests/e2e/utils/env.ts b/tests/e2e/utils/env.ts new file mode 100644 index 000000000000..dd80596f0698 --- /dev/null +++ b/tests/e2e/utils/env.ts @@ -0,0 +1,13 @@ +const global: {[name: string]: any} = Object.create(null); + + +export function setGlobalVariable(name: string, value: any) { + global[name] = value; +} + +export function getGlobalVariable(name: string): any { + if (!(name in global)) { + throw new Error(`Trying to access variable "${name}" but it's not defined.`); + } + return global[name]; +} diff --git a/tests/e2e/utils/fs.ts b/tests/e2e/utils/fs.ts index 5f42c98f1b93..f6a56ee76d43 100644 --- a/tests/e2e/utils/fs.ts +++ b/tests/e2e/utils/fs.ts @@ -1,4 +1,6 @@ import * as fs from 'fs'; +import {dirname} from 'path'; +import {stripIndents} from 'common-tags'; export function readFile(fileName: string) { @@ -52,6 +54,30 @@ export function moveFile(from: string, to: string) { } +function _recursiveMkDir(path: string) { + if (fs.existsSync(path)) { + return Promise.resolve(); + } else { + return _recursiveMkDir(dirname(path)) + .then(() => fs.mkdirSync(path)); + } +} + +export function copyFile(from: string, to: string) { + return _recursiveMkDir(dirname(to)) + .then(() => new Promise((resolve, reject) => { + const rd = fs.createReadStream(from); + rd.on('error', (err) => reject(err)); + + const wr = fs.createWriteStream(to); + wr.on('error', (err) => reject(err)); + wr.on('close', (ex) => resolve()); + + rd.pipe(wr); + })); +} + + export function writeMultipleFiles(fs: any) { return Object.keys(fs) .reduce((previous, curr) => { @@ -85,12 +111,29 @@ export function expectFileToMatch(fileName: string, regEx: RegExp | string) { .then(content => { if (typeof regEx == 'string') { if (content.indexOf(regEx) == -1) { - throw new Error(`File "${fileName}" did not contain "${regEx}"...`); + throw new Error(stripIndents`File "${fileName}" did not contain "${regEx}"... + Content: + ${content} + ------ + `); } } else { if (!content.match(regEx)) { - throw new Error(`File "${fileName}" did not match regex ${regEx}...`); + throw new Error(stripIndents`File "${fileName}" did not contain "${regEx}"... + Content: + ${content} + ------ + `); } } }); } + +export function expectFileSizeToBeUnder(fileName: string, sizeInBytes: number) { + return readFile(fileName) + .then(content => { + if (content.length > sizeInBytes) { + throw new Error(`File "${fileName}" exceeded file size of "${sizeInBytes}".`); + } + }); +} diff --git a/tests/e2e/utils/process.ts b/tests/e2e/utils/process.ts index d8d9a69be1d8..4746453ec424 100644 --- a/tests/e2e/utils/process.ts +++ b/tests/e2e/utils/process.ts @@ -20,8 +20,15 @@ function _exec(options: ExecOptions, cmd: string, args: string[]): Promise x !== undefined); - - console.log(blue(` Running \`${cmd} ${args.map(x => `"${x}"`).join(' ')}\`...`)); + const flags = [ + options.silent && 'silent', + options.waitForMatch && `matching(${options.waitForMatch})` + ] + .filter(x => !!x) // Remove false and undefined. + .join(', ') + .replace(/^(.+)$/, ' [$1]'); // Proper formatting. + + console.log(blue(` Running \`${cmd} ${args.map(x => `"${x}"`).join(' ')}\`${flags}...`)); console.log(blue(` CWD: ${cwd}`)); const spawnOptions: any = {cwd}; @@ -31,8 +38,8 @@ function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { + const childProcess = child_process.spawn(cmd, args, spawnOptions); + childProcess.stdout.on('data', (data: Buffer) => { stdout += data.toString('utf-8'); if (options.silent) { return; @@ -42,7 +49,7 @@ function _exec(options: ExecOptions, cmd: string, args: string[]): Promise line !== '') .forEach(line => console.log(' ' + line)); }); - npmProcess.stderr.on('data', (data: Buffer) => { + childProcess.stderr.on('data', (data: Buffer) => { stderr += data.toString('utf-8'); data.toString('utf-8') .split(/[\n\r]+/) @@ -50,13 +57,13 @@ function _exec(options: ExecOptions, cmd: string, args: string[]): Promise console.error(yellow(' ' + line))); }); - _processes.push(npmProcess); + _processes.push(childProcess); // Create the error here so the stack shows who called this function. const err = new Error(`Running "${cmd} ${args.join(' ')}" returned error code `); return new Promise((resolve, reject) => { - npmProcess.on('exit', (error: any) => { - _processes = _processes.filter(p => p !== npmProcess); + childProcess.on('exit', (error: any) => { + _processes = _processes.filter(p => p !== childProcess); if (!error) { resolve(stdout); @@ -67,7 +74,7 @@ function _exec(options: ExecOptions, cmd: string, args: string[]): Promise { + childProcess.stdout.on('data', (data: Buffer) => { if (data.toString().match(options.waitForMatch)) { resolve(stdout); } @@ -113,6 +120,10 @@ export function npm(...args: string[]) { return _exec({}, 'npm', args); } +export function node(...args: string[]) { + return _exec({}, 'node', args); +} + export function git(...args: string[]) { return _exec({}, 'git', args); } diff --git a/tests/e2e_runner.js b/tests/e2e_runner.js index d173b9080ad9..bc96d6f0f535 100644 --- a/tests/e2e_runner.js +++ b/tests/e2e_runner.js @@ -2,6 +2,8 @@ 'use strict'; require('../lib/bootstrap-local'); +Error.stackTraceLimit = Infinity; + /** * This file is ran using the command line, not using Jasmine / Mocha. */ @@ -20,6 +22,7 @@ const white = chalk.white; /** * Here's a short description of those flags: * --debug If a test fails, block the thread so the temporary directory isn't deleted. + * --noproject Skip creating a project or using one. * --nolink Skip linking your local angular-cli directory. Can save a few seconds. * --nightly Install angular nightly builds over the test project. * --reuse=/path Use a path instead of create a new project. That project should have been @@ -28,7 +31,7 @@ const white = chalk.white; * If unnamed flags are passed in, the list of tests will be filtered to include only those passed. */ const argv = minimist(process.argv.slice(2), { - 'boolean': ['debug', 'nolink', 'nightly'], + 'boolean': ['debug', 'nolink', 'nightly', 'noproject'], 'string': ['reuse'] }); @@ -53,9 +56,9 @@ const testsToRun = allSetups } return argv._.some(argName => { - return path.join(process.cwd(), argName) == path.join(__dirname, name) - || argName == name - || argName == name.replace(/\.ts$/, ''); + return path.join(process.cwd(), argName) == path.join(__dirname, 'e2e', name) + || argName == name + || argName == name.replace(/\.ts$/, ''); }); })); @@ -88,12 +91,14 @@ testsToRun.reduce((previous, relativeName) => { throw new Error('Invalid test module.'); }; + let clean = true; return Promise.resolve() .then(() => printHeader(currentFileName)) - .then(() => fn(argv)) + .then(() => fn(argv, () => clean = false)) .then(() => { - // Only clean after a real test, not a setup step. - if (allSetups.indexOf(relativeName) == -1) { + // Only clean after a real test, not a setup step. Also skip cleaning if the test + // requested an exception. + if (allSetups.indexOf(relativeName) == -1 && clean) { return gitClean(); } }) diff --git a/tsconfig.json b/tsconfig.json index 8a6e1ba0aad8..1055b93bdb0f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "rootDir": ".", "sourceMap": true, "sourceRoot": "", + "inlineSourceMap": true, "target": "es5", "lib": ["es6"], "baseUrl": "", @@ -24,7 +25,7 @@ "angular-cli/*": [ "./packages/angular-cli/*" ], "@angular-cli/ast-tools": [ "./packages/ast-tools/src" ], "@angular-cli/base-href-webpack": [ "./packages/base-href-webpack/src" ], - "@angular-cli/webpack": [ "./packages/webpack/src" ] + "@ngtools/webpack": [ "./packages/webpack/src" ] } }, "exclude": [