From 8d2d93a7a25437e89fd558b63482bce23404d29b Mon Sep 17 00:00:00 2001 From: Filipe Silva Date: Thu, 9 Feb 2017 23:14:29 +0000 Subject: [PATCH] feat(e2e): use protractor api (#4527) Uses existing Protractor API to run it directly instead of using `npm run e2e`. Also adds support for the following flags: `--serve`, `--config`, `--specs`, `--element-explorer`, `--webdriver-update`. Fix #4256 Fix #4478 BREAKING CHANGE: `ng e2e` no longer needs `ng serve` to be running. --- docs/documentation/e2e.md | 19 +++++- .../cli/blueprints/ng2/files/package.json | 3 +- packages/@angular/cli/commands/build.ts | 4 +- packages/@angular/cli/commands/e2e.ts | 51 +++++++++++++++- packages/@angular/cli/commands/serve.ts | 46 +++++++------- packages/@angular/cli/tasks/e2e.ts | 60 +++++++++++++------ packages/@angular/cli/tasks/lint.ts | 4 +- packages/@angular/cli/tasks/serve.ts | 8 ++- packages/@angular/cli/tasks/test.ts | 4 +- .../cli/utilities/require-project-module.ts | 8 +-- tests/e2e/tests/misc/minimal-config.ts | 6 +- tests/e2e/tests/test/e2e.ts | 52 +++++++++++----- 12 files changed, 187 insertions(+), 78 deletions(-) diff --git a/docs/documentation/e2e.md b/docs/documentation/e2e.md index c6a749d3a745..dd226eef9ae7 100644 --- a/docs/documentation/e2e.md +++ b/docs/documentation/e2e.md @@ -3,7 +3,7 @@ # ng e2e ## Overview -`ng e2e` executes end-to-end tests +`ng e2e` serves the application and runs end-to-end tests ### Running end-to-end tests @@ -11,6 +11,19 @@ ng e2e ``` -Before running the tests make sure you are serving the app via `ng serve`. - End-to-end tests are run via [Protractor](https://angular.github.io/protractor/). + +## Options +`--config` (`-c`) use a specific config file. Defaults to the protractor config file in `angular-cli.json`. + +`--specs` (`-sp`) override specs in the protractor config. +Can send in multiple specs by repeating flag (`ng e2e --specs=spec1.ts --specs=spec2.ts`). + +`--element-explorer` (`-ee`) start Protractor's +[Element Explorer](https://github.com/angular/protractor/blob/master/docs/debugging.md#testing-out-protractor-interactively) +for debugging. + +`--webdriver-update` (`-wu`) try to update webdriver. + +`--serve` (`-s`) compile and serve the app. +All non-reload related serve options are also available (e.g. `--port=4400`). \ No newline at end of file diff --git a/packages/@angular/cli/blueprints/ng2/files/package.json b/packages/@angular/cli/blueprints/ng2/files/package.json index 62e05954a163..c9cdf5474aed 100644 --- a/packages/@angular/cli/blueprints/ng2/files/package.json +++ b/packages/@angular/cli/blueprints/ng2/files/package.json @@ -8,8 +8,7 @@ "start": "ng serve", "test": "ng test", "lint": "ng lint", - "pree2e": "webdriver-manager update --standalone false --gecko false", - "e2e": "protractor" + "e2e": "ng e2e" }, "private": true, "dependencies": { diff --git a/packages/@angular/cli/commands/build.ts b/packages/@angular/cli/commands/build.ts index 0a26b31447a2..caaa241f3649 100644 --- a/packages/@angular/cli/commands/build.ts +++ b/packages/@angular/cli/commands/build.ts @@ -4,7 +4,7 @@ import { Version } from '../upgrade/version'; const Command = require('../ember-cli/lib/models/command'); // defaults for BuildOptions -export const BaseBuildCommandOptions: any = [ +export const baseBuildCommandOptions: any = [ { name: 'target', type: String, @@ -42,7 +42,7 @@ const BuildCommand = Command.extend({ description: 'Builds your app and places it into the output path (dist/ by default).', aliases: ['b'], - availableOptions: BaseBuildCommandOptions.concat([ + availableOptions: baseBuildCommandOptions.concat([ { name: 'watch', type: Boolean, default: false, aliases: ['w'] } ]), diff --git a/packages/@angular/cli/commands/e2e.ts b/packages/@angular/cli/commands/e2e.ts index ad476b19087c..094374b3568d 100644 --- a/packages/@angular/cli/commands/e2e.ts +++ b/packages/@angular/cli/commands/e2e.ts @@ -1,11 +1,34 @@ -const Command = require('../ember-cli/lib/models/command'); +const SilentError = require('silent-error'); + import { CliConfig } from '../models/config'; +import { ServeTaskOptions, baseServeCommandOptions } from './serve'; +const Command = require('../ember-cli/lib/models/command'); + + +export interface E2eTaskOptions extends ServeTaskOptions { + config: string; + serve: boolean; + webdriverUpdate: boolean; + specs: string[]; + elementExplorer: boolean; +} + +export const e2eCommandOptions = baseServeCommandOptions.concat([ + { name: 'config', type: String, aliases: ['c'] }, + { name: 'specs', type: Array, default: [], aliases: ['sp'] }, + { name: 'element-explorer', type: Boolean, default: false, aliases: ['ee'] }, + { name: 'webdriver-update', type: Boolean, default: true, aliases: ['wu'] }, + { name: 'serve', type: Boolean, default: true, aliases: ['s'] } +]); + const E2eCommand = Command.extend({ name: 'e2e', + aliases: ['e'], description: 'Run e2e tests in existing project', works: 'insideProject', - run: function () { + availableOptions: e2eCommandOptions, + run: function (commandOptions: E2eTaskOptions) { const E2eTask = require('../tasks/e2e').E2eTask; this.project.ngConfig = this.project.ngConfig || CliConfig.fromProject(); @@ -14,7 +37,29 @@ const E2eCommand = Command.extend({ project: this.project }); - return e2eTask.run(); + if (!commandOptions.config) { + const e2eConfig = CliConfig.fromProject().config.e2e; + + if (!e2eConfig.protractor.config) { + throw new SilentError('No protractor config found in angular-cli.json.'); + } + + commandOptions.config = e2eConfig.protractor.config; + } + + if (commandOptions.serve) { + const ServeTask = require('../tasks/serve').default; + + const serve = new ServeTask({ + ui: this.ui, + project: this.project, + }); + + // Protractor will end the proccess, so we don't need to kill the dev server + return serve.run(commandOptions, () => e2eTask.run(commandOptions)); + } else { + return e2eTask.run(commandOptions); + } } }); diff --git a/packages/@angular/cli/commands/serve.ts b/packages/@angular/cli/commands/serve.ts index c1ba57290d12..f736ce16f1f8 100644 --- a/packages/@angular/cli/commands/serve.ts +++ b/packages/@angular/cli/commands/serve.ts @@ -1,6 +1,6 @@ import * as denodeify from 'denodeify'; import { BuildOptions } from '../models/build-options'; -import { BaseBuildCommandOptions } from './build'; +import { baseBuildCommandOptions } from './build'; import { CliConfig } from '../models/config'; import { Version } from '../upgrade/version'; import { ServeTaskOptions } from './serve'; @@ -32,21 +32,35 @@ export interface ServeTaskOptions extends BuildOptions { hmr?: boolean; } +// Expose options unrelated to live-reload to other commands that need to run serve +export const baseServeCommandOptions: any = baseBuildCommandOptions.concat([ + { name: 'port', type: Number, default: defaultPort, aliases: ['p'] }, + { + name: 'host', + type: String, + default: defaultHost, + aliases: ['H'], + description: `Listens only on ${defaultHost} by default` + }, + { name: 'proxy-config', type: 'Path', aliases: ['pc'] }, + { 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: 'open', + type: Boolean, + default: false, + aliases: ['o'], + description: 'Opens the url in default browser', + } +]); + const ServeCommand = Command.extend({ name: 'serve', description: 'Builds and serves your app, rebuilding on file changes.', aliases: ['server', 's'], - availableOptions: BaseBuildCommandOptions.concat([ - { name: 'port', type: Number, default: defaultPort, aliases: ['p'] }, - { - name: 'host', - type: String, - default: defaultHost, - aliases: ['H'], - description: `Listens only on ${defaultHost} by default` - }, - { name: 'proxy-config', type: 'Path', aliases: ['pc'] }, + availableOptions: baseServeCommandOptions.concat([ { name: 'live-reload', type: Boolean, default: true, aliases: ['lr'] }, { name: 'live-reload-host', @@ -72,16 +86,6 @@ const ServeCommand = Command.extend({ default: true, description: 'Whether to live reload CSS (default true)' }, - { 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: 'open', - type: Boolean, - default: false, - aliases: ['o'], - description: 'Opens the url in default browser', - }, { name: 'hmr', type: Boolean, diff --git a/packages/@angular/cli/tasks/e2e.ts b/packages/@angular/cli/tasks/e2e.ts index 4de5cbac8fc7..ffbc335f851b 100644 --- a/packages/@angular/cli/tasks/e2e.ts +++ b/packages/@angular/cli/tasks/e2e.ts @@ -1,26 +1,50 @@ +import * as url from 'url'; + +import { E2eTaskOptions } from '../commands/e2e'; +import { requireProjectModule } from '../utilities/require-project-module'; const Task = require('../ember-cli/lib/models/task'); -import * as chalk from 'chalk'; -import {exec} from 'child_process'; export const E2eTask = Task.extend({ - run: function () { - const ui = this.ui; - let exitCode = 0; - - return new Promise((resolve) => { - exec(`npm run e2e -- ${this.project.ngConfig.config.e2e.protractor.config}`, - (err: NodeJS.ErrnoException, stdout: string, stderr: string) => { - ui.writeLine(stdout); - if (err) { - ui.writeLine(stderr); - ui.writeLine(chalk.red('Some end-to-end tests failed, see above.')); - exitCode = 1; - } else { - ui.writeLine(chalk.green('All end-to-end tests pass.')); - } - resolve(exitCode); + run: function (e2eTaskOptions: E2eTaskOptions) { + const projectRoot = this.project.root; + const protractorLauncher = requireProjectModule(projectRoot, 'protractor/built/launcher'); + + return new Promise(function () { + let promise = Promise.resolve(); + let additionalProtractorConfig: any = { + elementExplorer: e2eTaskOptions.elementExplorer + }; + + // use serve url as override for protractors baseUrl + if (e2eTaskOptions.serve) { + additionalProtractorConfig.baseUrl = url.format({ + protocol: e2eTaskOptions.ssl ? 'https' : 'http', + hostname: e2eTaskOptions.host, + port: e2eTaskOptions.port.toString() }); + } + + if (e2eTaskOptions.specs.length !== 0) { + additionalProtractorConfig['specs'] = e2eTaskOptions.specs; + } + + if (e2eTaskOptions.webdriverUpdate) { + // webdriver-manager can only be accessed via a deep import from within + // protractor/node_modules. A double deep import if you will. + const webdriverUpdate = requireProjectModule(projectRoot, + 'protractor/node_modules/webdriver-manager/built/lib/cmds/update'); + // run `webdriver-manager update --standalone false --gecko false --quiet` + promise = promise.then(() => webdriverUpdate.program.run({ + standalone: false, + gecko: false, + quiet: true + })); + } + + // Don't call resolve(), protractor will manage exiting the process itself + return promise.then(() => + protractorLauncher.init(e2eTaskOptions.config, additionalProtractorConfig)); }); } }); diff --git a/packages/@angular/cli/tasks/lint.ts b/packages/@angular/cli/tasks/lint.ts index df4fa00f53cc..403f917c8c4b 100644 --- a/packages/@angular/cli/tasks/lint.ts +++ b/packages/@angular/cli/tasks/lint.ts @@ -3,7 +3,7 @@ import * as chalk from 'chalk'; import * as path from 'path'; import * as glob from 'glob'; import * as ts from 'typescript'; -import { requireDependency } from '../utilities/require-project-module'; +import { requireProjectModule } from '../utilities/require-project-module'; import { CliConfig } from '../models/config'; import { LintCommandOptions } from '../commands/lint'; import { oneLine } from 'common-tags'; @@ -30,7 +30,7 @@ export default Task.extend({ return Promise.resolve(0); } - const tslint = requireDependency(projectRoot, 'tslint'); + const tslint = requireProjectModule(projectRoot, 'tslint'); const Linter = tslint.Linter; const Configuration = tslint.Configuration; diff --git a/packages/@angular/cli/tasks/serve.ts b/packages/@angular/cli/tasks/serve.ts index f3fbe27933fd..287d237596cb 100644 --- a/packages/@angular/cli/tasks/serve.ts +++ b/packages/@angular/cli/tasks/serve.ts @@ -16,7 +16,7 @@ const SilentError = require('silent-error'); const opn = require('opn'); export default Task.extend({ - run: function (serveTaskOptions: ServeTaskOptions) { + run: function (serveTaskOptions: ServeTaskOptions, rebuildDoneCb: any) { const ui = this.ui; let webpackCompiler: any; @@ -25,7 +25,7 @@ export default Task.extend({ const outputPath = serveTaskOptions.outputPath || appConfig.outDir; if (this.project.root === outputPath) { - throw new SilentError ('Output path MUST not be project root directory!'); + throw new SilentError('Output path MUST not be project root directory!'); } rimraf.sync(path.resolve(this.project.root, outputPath)); @@ -67,6 +67,10 @@ export default Task.extend({ webpackConfig.entry.main.unshift(...entryPoints); webpackCompiler = webpack(webpackConfig); + if (rebuildDoneCb) { + webpackCompiler.plugin('done', rebuildDoneCb); + } + const statsConfig = getWebpackStatsConfig(serveTaskOptions.verbose); let proxyConfig = {}; diff --git a/packages/@angular/cli/tasks/test.ts b/packages/@angular/cli/tasks/test.ts index d6dcba48a6fa..084d7c603ffc 100644 --- a/packages/@angular/cli/tasks/test.ts +++ b/packages/@angular/cli/tasks/test.ts @@ -1,13 +1,13 @@ const Task = require('../ember-cli/lib/models/task'); import { TestOptions } from '../commands/test'; import * as path from 'path'; -import { requireDependency } from '../utilities/require-project-module'; +import { requireProjectModule } from '../utilities/require-project-module'; export default Task.extend({ run: function (options: TestOptions) { const projectRoot = this.project.root; return new Promise((resolve) => { - const karma = requireDependency(projectRoot, 'karma'); + const karma = requireProjectModule(projectRoot, 'karma'); const karmaConfig = path.join(projectRoot, this.project.ngConfig.config.test.karma.config); let karmaOptions: any = Object.assign({}, options); diff --git a/packages/@angular/cli/utilities/require-project-module.ts b/packages/@angular/cli/utilities/require-project-module.ts index 464cb8120297..a655e99bad1c 100644 --- a/packages/@angular/cli/utilities/require-project-module.ts +++ b/packages/@angular/cli/utilities/require-project-module.ts @@ -1,8 +1,6 @@ -import * as path from 'path'; +const resolve = require('resolve'); // require dependencies within the target project -export function requireDependency(root: string, moduleName: string) { - const packageJson = require(path.join(root, 'node_modules', moduleName, 'package.json')); - const main = path.normalize(packageJson.main); - return require(path.join(root, 'node_modules', moduleName, main)); +export function requireProjectModule(root: string, moduleName: string) { + return require(resolve.sync(moduleName, { basedir: root })); } diff --git a/tests/e2e/tests/misc/minimal-config.ts b/tests/e2e/tests/misc/minimal-config.ts index d8ededab39d6..5e94c7545619 100644 --- a/tests/e2e/tests/misc/minimal-config.ts +++ b/tests/e2e/tests/misc/minimal-config.ts @@ -1,5 +1,5 @@ import { writeFile, writeMultipleFiles } from '../../utils/fs'; -import { runServeAndE2e } from '../test/e2e'; +import { ng } from '../../utils/process'; export default function () { @@ -15,7 +15,7 @@ export default function () { }], e2e: { protractor: { config: './protractor.conf.js' } } }))) - .then(() => runServeAndE2e()) + .then(() => ng('e2e')) .then(() => writeMultipleFiles({ './src/script.js': ` document.querySelector('app-root').innerHTML = '

app works!

'; @@ -40,5 +40,5 @@ export default function () { e2e: { protractor: { config: './protractor.conf.js' } } }), })) - .then(() => runServeAndE2e()); + .then(() => ng('e2e')); } diff --git a/tests/e2e/tests/test/e2e.ts b/tests/e2e/tests/test/e2e.ts index f22f708cb7b8..a40eb25fe83e 100644 --- a/tests/e2e/tests/test/e2e.ts +++ b/tests/e2e/tests/test/e2e.ts @@ -1,23 +1,45 @@ -import {ng, killAllProcesses} from '../../utils/process'; -import {expectToFail} from '../../utils/utils'; -import {ngServe} from '../../utils/project'; +import { + ng, + npm, + execAndWaitForOutputToMatch, + killAllProcesses +} from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; +import { expectToFail } from '../../utils/utils'; +import { moveFile, copyFile } from '../../utils/fs'; -export function runServeAndE2e(...args: string[]) { - return ngServe(...args) - .then(() => ng('e2e')) +export default function () { + // Should fail without updated webdriver + return updateJsonFile('package.json', packageJson => { + // Add to npm scripts to make running the binary compatible with Windows + const scripts = packageJson['scripts']; + scripts['wd:clean'] = 'webdriver-manager clean'; + }) + .then(() => npm('run', 'wd:clean')) + .then(() => expectToFail(() => ng('e2e', '--no-webdriver-update', '--no-serve'))) + // Should fail without serving + .then(() => expectToFail(() => ng('e2e', '--no-serve'))) + // These should work. + .then(() => ng('e2e', '--no-progress')) + .then(() => ng('e2e', '--prod', '--no-progress')) + // Should use port in baseUrl + .then(() => ng('e2e', '--port', '4400', '--no-progress')) + // Should accept different config file + .then(() => moveFile('./protractor.conf.js', './renamed-protractor.conf.js')) + .then(() => ng('e2e', '--config', './renamed-protractor.conf.js', '--no-progress')) + .then(() => moveFile('./renamed-protractor.conf.js', './protractor.conf.js')) + // Should accept different multiple spec files + .then(() => moveFile('./e2e/app.e2e-spec.ts', './e2e/renamed-app.e2e-spec.ts')) + .then(() => copyFile('./e2e/renamed-app.e2e-spec.ts', './e2e/another-app.e2e-spec.ts')) + .then(() => ng('e2e', '--specs', './e2e/renamed-app.e2e-spec.ts', + '--specs', './e2e/another-app.e2e-spec.ts', '--no-progress')) + // Should start up Element Explorer + .then(() => execAndWaitForOutputToMatch('ng', ['e2e', '--element-explorer', '--no-progress'], + /Element Explorer/)) .then(() => killAllProcesses(), (err: any) => { killAllProcesses(); throw err; }); -} -export default function() { - // This is supposed to fail without serving first... - return expectToFail(() => ng('e2e')) - // These should work. - .then(() => runServeAndE2e()) - .then(() => runServeAndE2e('--prod')) - .then(() => runServeAndE2e('--aot')) - .then(() => runServeAndE2e('--aot', '--prod')); }