diff --git a/docs/acceptance.md b/docs/acceptance.md index fc4ba2b70..54089c15d 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -168,49 +168,28 @@ To see all possible assertions see the helper's reference. Sometimes you need to retrieve a data from a page to use it in next steps of a scenario. Imagine, application generates a password and you want to ensure that user can login using this password. -There are two ways to do this: -1. Generators - ```js - I.fillField('email', 'miles@davis.com') - I.click('Generate Password'); - const password = yield I.grabTextFrom('#password'); - I.click('Login'); - I.fillField('email', 'miles@davis.com'); - I.fillField('password', password); - I.click('Log in!'); - ``` - - `grabTextFrom` action is used here to retrieve text from an element. All actions starting with `grab` prefix are expected to return data. In order to synchronize this step with a scenario you should pause test execution with `yield` keyword of ES6. To make it work your test should be written inside a generator function (notice `*` in its definition): - - ```js - Scenario('use page title', function*(I) { - // ... - const password = yield I.grabTextFrom('#password'); - I.fillField('password', password); - }); - ``` - - 2. Async/Await - ```js - I.fillField('email', 'miles@davis.com') - I.click('Generate Password'); - const password = await I.grabTextFrom('#password'); - I.click('Login'); - I.fillField('email', 'miles@davis.com'); - I.fillField('password', password); - I.click('Log in!'); - ``` - - `grabTextFrom` action is used here to retrieve text from an element. All actions starting with `grab` prefix are expected to return data. In order to synchronize this step with a scenario you should pause test execution with `await` keyword of ES6. To make it work your test should be written inside a async function (notice `async` in its definition). To use `async/await` you have to use node v8.9.1 or higher: - - ```js - Scenario('use page title', async (I) => { - // ... - const password = await I.grabTextFrom('#password'); - I.fillField('password', password); - }); - ``` - +```js +Scenario('login with generated password', async (I) => { + I.fillField('email', 'miles@davis.com'); + I.click('Generate Password'); + const password = await I.grabTextFrom('#password'); + I.click('Login'); + I.fillField('email', 'miles@davis.com'); + I.fillField('password', password); + I.click('Log in!'); + I.see('Hello, Miles'); +}); +``` + +`grabTextFrom` action is used here to retrieve text from an element. All actions starting with `grab` prefix are expected to return data. In order to synchronize this step with a scenario you should pause test execution with `await` keyword of ES6. To make it work your test should be written inside a async function (notice `async` in its definition). + +```js +Scenario('use page title', async (I) => { + // ... + const password = await I.grabTextFrom('#password'); + I.fillField('password', password); +}); +``` ## Waiting diff --git a/docs/mobile.md b/docs/mobile.md index a5c6d030f..7db1c9cae 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -52,9 +52,15 @@ appium To run mobile test you need either an device emulator (available with Android SDK or iOS), real device connected for mobile testing. Alternatively, you may execute Appium with device emulator inside Docker container. +CodeceptJS should be installed with webdriverio support: + +```bash +npm install -g codeceptjs-webdriverio +``` + ## Configuring -CodeceptJS should be installed. Initialize it with `init` command: +Initialize CodeceptJS with `init` command: ```sh codeceptjs init @@ -66,7 +72,7 @@ Select [Appium helper](http://codecept.io/helpers/Appium/) when asked. ? What helpers do you want to use? ◯ WebDriverIO ◯ Protractor - ◯ SeleniumWebdriver + ◯ Puppeteer ◯ Nightmare ❯◉ Appium ◯ REST diff --git a/docs/nightmare.md b/docs/nightmare.md index a5cd58f09..de50eb679 100644 --- a/docs/nightmare.md +++ b/docs/nightmare.md @@ -106,7 +106,7 @@ Setup process is explained on [QuickStart page](http://codecept.io/quickstart/). ## Configuring Nightmare -To enable Nightmare tests you should enable `Nightmare` helper in `codecept.json` config: +Enable `Nightmare` helper in `codecept.json` config: ```js { // .. @@ -151,7 +151,7 @@ As a small bonus: all `console.log` calls on a page will be also shown in `--deb ## Manipulating Web Page -Nightmare helper is supposed to work in the same manner as WebDriverIO, SeleniumWebdriverJS or Protractor. +Nightmare helper is supposed to work in the same manner as WebDriverIO or Protractor. This means that all CodeceptJS actions like `click`, `fillField`, `selectOption` and others are supposed to work in the very same manner. They are expressive and flexible to accept CSS, XPath, names, values, or strict locators. Follow the helper reference for detailed description. diff --git a/docs/puppeteer.md b/docs/puppeteer.md new file mode 100644 index 000000000..bf43770f8 --- /dev/null +++ b/docs/puppeteer.md @@ -0,0 +1,176 @@ +# Robust Chrome Testing with Puppeteer + +Among all Selenium alternatives the most interesting emerging ones are tools developed around Google Chrome [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/). And the most prominent one is [Puppeteer](https://github.com/GoogleChrome/puppeteer). +It operates over Google Chrome directly without requireing additional tools like ChromeDriver. So tests setup with Puppeteer can be started with npm install only. If want get faster and simpler to setup tests, Puppeteer would be your choice. + +**CodeceptJS uses Puppeteer to improve end to end testing experience. First: you don't need to learn syntax of new tool, as all drivers in CodeceptJS share the same API. Second: CodeceptJS can locate elements by XPath.**. + +Take a look at a sample test: + +```js +I.amOnPage('https://github.com'); +I.click('Sign in', '//html/body/div[1]/header'); +I.see('Sign in to GitHub', 'h1'); +I.fillField('Username or email address', 'something@totest.com'); +I.fillField('Password', '123456'); +I.click('Sign in'); +I.see('Incorrect username or password.', '.flash-error'); +``` + +It's readable and simple, contains XPath and works using Puppeteer API! + +## Setup + +To start you need CodeceptJS with Puppeteer packages installed + +```bash +npm install codeceptjs-puppeteer +``` + +And a basic project initialized + +```sh +codeceptjs init +``` + +You will be asked for a Helper to use, you should select Puppeteer and provide url of a website you are testing. +Setup process is explained on [QuickStart page](http://codecept.io/quickstart/). + +(If you already have CodeceptJS project, just install `puppeteer` package and enable it in config) + +## Configuring + +Make sure `Puppeteer` helper is enabled in `codecept.json` config: + +```js +{ // .. + "helpers": { + "Puppeteer": { + "url": "http://localhost", + "show": false + } + } + // .. +} +``` + +Turn on the `show` option if you want to follow test progress in a window. This is very useful for debugging. + +Sometimes test may run faster than application gets rendered. In this case it is **recommended to increase `waitForAction` config value**. It will wait for a small amount of time (100ms) by default after each user action taken. + +*More options are listed in [helper reference](http://codecept.io/helpers/Puppeteer/).* + +## Writing Tests + +CodeceptJS test should be created with `gt` command: + +``` +codeceptjs gt +``` + +As an example we will use `ToDoMvc` app for testing. + +### Actions + +Tests consist with a scenario of user's action taken on a page. The most widely used ones are: + +* `amOnPage` - to open a webpage (accepts relative or absolute url) +* `click` - to locate a button or link and click on it +* `fillField` - to enter a text inside a field +* `selectOption`, `checkOption` - to interact with a form +* `wait*` to wait for some parts of page to be fully rendered (important for testing SPA) +* `grab*` to get values from page sources +* `see`, `dontSee` - to check for a text on a page +* `seeElement`, `dontSeeElement` - to check for elements on a page + +*All actions are listed in [helper reference](http://codecept.io/helpers/Puppeteer/).* + +All actions whicn interact with elements **support CSS and XPath locators**. Actions like `click` or `fillField` by locate elements by their name or value on a page: + +```js + +// search for link or button +I.click('Login'); +// locate field by its label +I.fillField('Name', 'Miles'); +// we can use input name +I.fillField('user[email]','miles@davis.com'); +``` + +You can also specify the exact locator type with strict locators: + +```js +I.click({css: 'button.red'}); +I.fillField({name: 'user[email]'},'miles@davis.com'); +I.seeElement({xpath: '//body/header'}); +``` + +A complete ToDo-MVC test may look like: + +```js +Scenario('create todo item', (I) => { + I.amOnPage('http://todomvc.com/examples/react/'); + I.dontSeeElement('.todo-count'); + I.fillField('What needs to be done?', 'Write a guide'); + I.pressKey('Enter'); + I.see('Write a guide', '.todo-list'); + I.see('1 item left', '.todo-count'); +}); +``` + +### Grabbers + +If you need to get element's value inside a test you can use `grab*` methods. They should be used with `await` operator inside `async` function: + +```js +const assert = require('assert'); +Scenario('get value of current tasks', async (I) => { + I.createTodo('do 1'); + I.createTodo('do 2'); + let numTodos = await I.grabTextFrom('.todo-count strong'); + assert.equal(2, numTodos); +}); +``` + +### Within + +In case some actions should be taken inside one element (a container or modal window) you can use `within` block to narrow the scope. +Please take a not that you can't use within inside another within in Puppeteer helper: + +```js +within('.todoapp', () => { + I.createTodo('my new item'); + I.see('1 item left', '.todo-count'); + I.click('.todo-list input.toggle'); +}); +I.see('0 items left', '.todo-count'); +``` + +CodeceptJS allows you to implement custom actions like `I.createTodo` or use **PageObjects**. Learn how to improve your tests in [PageObjects](http://codecept.io/pageobjects/) guide. + +## Extending + +Puppeteer has a very [rich and flexible API](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md). Sure, you can extend your test suites to use the methods listed there. CodeceptJS already prepares some objects for you and you can use them from your you helpers. + +Start with creating an `MyPuppeteer` helper using `generate:helper` or `gh` command: + +``` +codeceptjs gh +``` +Then inside a Helper you can access `Puppeteer` helper of CodeceptJS. +Let's say you want to create `I.renderPageToPdf` action. In this case you need to call `pdf` method of `page` object + +```js +// inside a MyPuppeteer helper +async renderPageToPdf() { + const page = this.helpers['Puppeteer'].page; + await page.emulateMedia('screen'); + return page.pdf({path: 'page.pdf'}); +} +``` + +The same way you can also access `browser` object to implement more actions or handle events. [Learn more about Helpers](http://codecept.io/helpers/) in the corresponding guide. + +## done() + +Yes, also the [demo project is available on GitHub](https://github.com/DavertMik/codeceptjs-todomvc-puppeteer) \ No newline at end of file diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 1f6734fb0..60a4dc296 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -32,7 +32,7 @@ const puppeteer = requireg('puppeteer'); * * `waitForAction`: (optional) how long to wait after click, doubleClick or PressKey actions in ms. Default: 100. * * `waitForTimeout`: (optional) default wait* timeout in ms. Default: 1000. * * `windowSize`: (optional) default window size. Set a dimension like `640x480`. - * * `chrome`: (optional) pass additional Google Chrome options. Example + * * `chrome`: (optional) pass additional [Puppeteer run options](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions). Example * * ```js * "chrome": { @@ -898,6 +898,7 @@ async function proceedSee(assertType, text, context) { const locator = new Locator(context, 'css'); description = `element ${locator.toString()}`; const els = await this._locate(locator); + assertElementExists(els, 'context element'); allText = await Promise.all(els.map(el => el.getProperty('innerText').then(p => p.jsonValue()))); } diff --git a/lib/scenario.js b/lib/scenario.js index 304fe04b9..592fc47c3 100644 --- a/lib/scenario.js +++ b/lib/scenario.js @@ -1,9 +1,7 @@ const event = require('./event'); const container = require('./container'); const recorder = require('./recorder'); -const getParamNames = require('./utils').getParamNames; -const isGenerator = require('./utils').isGenerator; - +const { getParamNames, isGenerator, isAsyncFunction } = require('./utils'); const resumeTest = function (res, done, error, returnStatement) { recorder.add('create new promises queue for generator', (data) => { @@ -61,7 +59,7 @@ module.exports.test = (test) => { recorder.add(() => done(err)); }); - if (testFn[Symbol.toStringTag] === 'AsyncFunction') { + if (isAsyncFunction(testFn)) { event.emit(event.test.started, test); testFn.apply(test, getInjectedArguments(testFn, test)).then((res) => { recorder.add('fire test.passed', () => event.emit(event.test.passed, test)); @@ -74,32 +72,32 @@ module.exports.test = (test) => { event.emit(event.test.failed, test, err); recorder.add(() => done(err)); }); - } else { - try { - event.emit(event.test.started, test); - const res = testFn.apply(test, getInjectedArguments(testFn, test)); - if (isGenerator(testFn)) { - try { - res.next(); // running test - } catch (err) { - event.emit(event.test.failed, test, err); - done(err); - return test; - } - recorder.catch(); // catching possible errors in promises - resumeTest(res, () => { - recorder.add('fire test.passed', () => event.emit(event.test.passed, test)); - recorder.add('finish generator with no error', () => done()); // finish him - }); + return; + } + try { + event.emit(event.test.started, test); + const res = testFn.apply(test, getInjectedArguments(testFn, test)); + if (isGenerator(testFn)) { + try { + res.next(); // running test + } catch (err) { + event.emit(event.test.failed, test, err); + done(err); + return test; } - } catch (err) { - recorder.throw(err); - } finally { - if (!isGenerator(testFn)) { + recorder.catch(); // catching possible errors in promises + resumeTest(res, () => { recorder.add('fire test.passed', () => event.emit(event.test.passed, test)); - recorder.add('finish test', () => done()); - recorder.catch(); - } + recorder.add('finish generator with no error', () => done()); // finish him + }); + } + } catch (err) { + recorder.throw(err); + } finally { + if (!isGenerator(testFn)) { + recorder.add('fire test.passed', () => event.emit(event.test.passed, test)); + recorder.add('finish test', () => done()); + recorder.catch(); } } }; @@ -120,7 +118,7 @@ module.exports.injected = function (fn, suite, hookName) { recorder.add(() => done(err)); }); - if (fn[Symbol.toStringTag] === 'AsyncFunction') { + if (isAsyncFunction(fn)) { event.emit(event.hook.started, suite); recorder.startUnlessRunning(); this.test.body = fn.toString(); @@ -137,33 +135,34 @@ module.exports.injected = function (fn, suite, hookName) { if (hookName === 'afterSuite') event.emit(event.suite.after, suite); recorder.add(() => done(err)); }); - } else { - try { - event.emit(event.hook.started, suite); - recorder.startUnlessRunning(); - this.test.body = fn.toString(); - const res = fn.apply(this, getInjectedArguments(fn)); - if (isGenerator(fn)) { - try { - res.next(); // running test - } catch (err) { - event.emit(event.test.failed, event.test, err); - done(err); - } - recorder.catch(); // catching possible errors in promises - resumeTest(res, () => { - recorder.add('fire hook.passed', () => event.emit(event.hook.passed, suite)); - recorder.add(`finish ${hookName} hook`, () => done()); - }); + return; + } + + try { + event.emit(event.hook.started, suite); + recorder.startUnlessRunning(); + this.test.body = fn.toString(); + const res = fn.apply(this, getInjectedArguments(fn)); + if (isGenerator(fn)) { + try { + res.next(); // running test + } catch (err) { + event.emit(event.test.failed, event.test, err); + done(err); } - } catch (err) { - recorder.throw(err); - } finally { - if (!isGenerator(fn)) { + recorder.catch(); // catching possible errors in promises + resumeTest(res, () => { recorder.add('fire hook.passed', () => event.emit(event.hook.passed, suite)); recorder.add(`finish ${hookName} hook`, () => done()); - recorder.catch(); - } + }); + } + } catch (err) { + recorder.throw(err); + } finally { + if (!isGenerator(fn)) { + recorder.add('fire hook.passed', () => event.emit(event.hook.passed, suite)); + recorder.add(`finish ${hookName} hook`, () => done()); + recorder.catch(); } } }; diff --git a/lib/utils.js b/lib/utils.js index 3d5092243..3c54f5a4a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -27,6 +27,10 @@ const isGenerator = module.exports.isGenerator = function (fn) { return fn.constructor.name === 'GeneratorFunction'; }; +const isAsyncFunction = module.exports.isAsyncFunction = function (fn) { + return fn[Symbol.toStringTag] === 'AsyncFunction'; +}; + module.exports.fileExists = function (filePath) { try { fs.statSync(filePath);