Repository: Codeception/CodeceptJS
Pull request #1504 (2019-02-15 18:27:39)

New puppeteer plugin to dump code coverage
After creating your beautiful tests, you'll eventually get asked what is your code coverage.

This plugin will dump the code coverage for you.

You'll need to convert the coverage to istanbul readable coverage and run istanbul to create the report.
(I'll provide my script that does exactly that when I get time to clean it up for public use).

codacy-bot: 
![Codacy](https://app.codacy.com/assets/images/favicon.png) Here is an overview of what got changed by this pull request:


```diff

Issues
======
- Added 3
           
```


See the complete overview on [Codacy](https://app.codacy.com/app/DavertMik/CodeceptJS/pullRequest?prid=3110152&bid=11368226)
        

Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index 56f3125d..dfe55be2 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,3 +1,17 @@
+## 2.0.4
+
+* [WebDriver][Protractor][Nightmare][Puppeteer] `grabAttributeFrom` returns an array when multiple elements matched. By @PeterNgTr
+* [autoLogin plugin] Fixed merging users config by @nealfennimore
+* [autoDelay plugin] Added WebDriver to list of supported helpers by @mattin4d
+* [Appium] Fixed using locators in `waitForElement`, `waitForVisible`, `waitForInvisible`. By @eduardofinotti
+* [allure plugin] Add tags to allure reports by @Vorobeyko
+* [allure plugin] Add skipped tests to allure reports by @Vorobeyko
+* Fixed `Logged Test name | [object Object]` when used Data().Scenario(). By @Vorobeyko
+* Fixed Data().only.Scenario() to run for all datasets. By @Vorobeyko
+* [WebDriver] `attachFile` to work with hidden elements. Fixed in [#1460](https://github.com/Codeception/CodeceptJS/pull/1460) by @tsuemura
+
+
+
 ## 2.0.3
 
 * [**autoLogin plugin**](https://codecept.io/plugins#autologin) added. Allows to log in once and reuse browser session. When session expires - automatically logs in again. Can persist session between runs by saving cookies to file.
diff --git a/docs/locators.md b/docs/locators.md
index 9704f6b5..c1e9c7b8 100644
--- a/docs/locators.md
+++ b/docs/locators.md
@@ -137,6 +137,15 @@ Finds an element which contains a child element provided:
 locate('form').withChild('select');
 ```
 
+#### withDescendant
+
+Finds an element which contains a descendant element provided:
+
+```js
+// finds form with <select> which is the descendant it
+locate('form').withDescendant('select');
+```
+
 #### withText
 
 Finds element containing a text
diff --git a/docs/translation.md b/docs/translation.md
index 61a2df57..df250129 100644
--- a/docs/translation.md
+++ b/docs/translation.md
@@ -146,3 +146,43 @@ Scenario('成功提交表單', (我) => {
     我.在當前網址中看不到('/documentation')
 });
 ```
+
+## Japanese
+
+Add to config
+
+```json
+  "translation": "ja-JP"
+```
+
+Now you can write test like this:
+
+```js
+Scenario('ログインできる', (私は) => {
+    私は.ページを移動する('/login');
+    私は.フィールドに入力する("Eメール", "foo@example.com");
+    私は.フィールドに入力する("パスワード", "p@ssword");
+    私は.クリックする('ログイン');
+    私は.待つ(1);
+    私は.URLに含まれるか確認する('/home');
+});
+```
+
+## Using your own translation file
+
+Create translation file like this:
+
+```js
+module.exports = {
+  I: '',
+  actions: {
+    click: 'Klicken',
+    wait: 'Wartenn',
+  }
+```
+
+And add the file path to your config
+
+```json
+    "translation": "./path/to/your/translation.js"
+```
diff --git a/docs/webapi/dragSlider.mustache b/docs/webapi/dragSlider.mustache
index dbef0cc6..dd2a996f 100644
--- a/docs/webapi/dragSlider.mustache
+++ b/docs/webapi/dragSlider.mustache
@@ -5,5 +5,6 @@ For fuzzy locators, fields are matched by label text, the "name" attribute, CSS,
 I.dragSlider('#slider', 30);
 I.dragSlider('#slider', -70);
 ```
-@param field located by label|name|CSS|XPath|strict locator.
-@param value position to drag.
\ No newline at end of file
+
+@param locator located by label|name|CSS|XPath|strict locator.
+@param offsetX position to drag.
\ No newline at end of file
diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js
index 64cabb5b..8a7e8405 100644
--- a/lib/helper/WebDriver.js
+++ b/lib/helper/WebDriver.js
@@ -99,6 +99,45 @@ const webRoot = 'body';
  * }
  * ```
  *
+ * ### Internet Explorer
+ * Additional configuration params can be used from [IE options](https://seleniumhq.github.io/selenium/docs/api/rb/Selenium/WebDriver/IE/Options.html)
+ *
+ * ```json
+ * {
+ *    "helpers": {
+ *      "WebDriver" : {
+ *        "url": "http://localhost",
+ *        "browser": "internet explorer",
+ *        "desiredCapabilities": {
+ *          "ieOptions": {
+ *            "ie.browserCommandLineSwitches": "-private",
+ *            "ie.usePerProcessProxy": true,
+ *            "ie.ensureCleanSession": true,
+ *          }
+ *        }
+ *      }
+ *    }
+ * }
+ * ```
+ *
+ * ### Selenoid Options
+ *
+ * ```json
+ * {
+ *    "helpers": {
+ *      "WebDriver" : {
+ *        "url": "http://localhost",
+ *        "browser": "chrome",
+ *        "desiredCapabilities": {
+ *          "selenoidOptions": {
+ *            "enableVNC": true,
+ *          }
+ *        }
+ *      }
+ *    }
+ * }
+ * ```
+ *
  * ### Connect through proxy
  *
  * CodeceptJS also provides flexible options when you want to execute tests to Selenium servers through proxy. You will
@@ -270,7 +309,7 @@ class WebDriver extends Helper {
     config = Object.assign(defaults, config);
 
 
-    config.hostname = config.host; // webdriverio spec
+    if (typeof config.host !== 'undefined') config.hostname = config.host; // webdriverio spec
     config.baseUrl = config.url || config.baseUrl;
     if (config.desiredCapabilities && Object.keys(config.desiredCapabilities).length) {
       config.capabilities = config.desiredCapabilities;
@@ -284,6 +323,14 @@ class WebDriver extends Helper {
       config.capabilities['moz:firefoxOptions'] = config.capabilities.firefoxOptions;
       delete config.capabilities.firefoxOptions;
     }
+    if (config.capabilities.ieOptions) {
+      config.capabilities['se:ieOptions'] = config.capabilities.ieOptions;
+      delete config.capabilities.ieOptions;
+    }
+    if (config.capabilities.selenoidOptions) {
+      config.capabilities['selenoid:options'] = config.capabilities.selenoidOptions;
+      delete config.capabilities.selenoidOptions;
+    }
 
     config.waitForTimeout /= 1000; // convert to seconds
 
@@ -1244,7 +1291,7 @@ class WebDriver extends Helper {
       const elem = usingFirstElement(res);
       const elementId = getElementId(elem);
       if (this.browser.isMobile) return this.browser.touchScroll(elementId, offsetX, offsetY);
-      const location = await this.browser.getElementLocation(elementId);
+      const location = await elem.getLocation();
       assertElementExists(location, 'Failed to receive', 'location');
       /* eslint-disable prefer-arrow-callback */
       return this.browser.execute(function (x, y) { return window.scrollTo(x, y); }, location.x + offsetX, location.y + offsetY);
@@ -1286,6 +1333,8 @@ class WebDriver extends Helper {
     }
 
     /* eslint-disable prefer-arrow-callback, comma-dangle, prefer-const */
+    const originalWindowSize = await this.browser.getWindowSize();
+
     let { width, height } = await this.browser.execute(function () {
       return {
         height: document.body.scrollHeight,
@@ -1298,7 +1347,9 @@ class WebDriver extends Helper {
 
     await this.browser.setWindowSize(width, height);
     this.debug(`Screenshot has been saved to ${outputFile}, size: ${width}x${height}`);
-    return this.browser.saveScreenshot(outputFile);
+    const buffer = await this.browser.saveScreenshot(outputFile);
+    await this.browser.setWindowSize(originalWindowSize.width, originalWindowSize.height);
+    return buffer;
   }
 
 
@@ -1479,6 +1530,29 @@ class WebDriver extends Helper {
     return sourceEl.dragAndDrop(destEl);
   }
 
+  /**
+   * {{> ../webapi/dragSlider }}
+   */
+  async dragSlider(locator, offsetX = 0) {
+    const browser = this.browser;
+    await this.moveCursorTo(locator);
+
+    // for chrome
+    if (browser.isW3C) {
+      return browser.performActions([
+        { type: 'pointerDown', button: 0 },
+        {
+          type: 'pointerMove', origin: 'pointer', duration: 1000, x: offsetX, y: 0,
+        },
+        { type: 'pointerUp', button: 0 },
+      ]);
+    }
+
+    await browser.buttonDown(0);
+    await browser.moveToElement(null, offsetX, 0);
+    await browser.buttonUp(0);
+  }
+
 
   /**
    * Close all tabs except for the current one.
diff --git a/lib/locator.js b/lib/locator.js
index afdf2fea..8629feaa 100644
--- a/lib/locator.js
+++ b/lib/locator.js
@@ -116,7 +116,12 @@ class Locator {
   }
 
   withChild(locator) {
-    const xpath = sprintf('%s[//%s]', this.toXPath(), removePrefix((new Locator(locator, 'css')).toXPath()));
+    const xpath = sprintf('%s//child::%s', this.toXPath(), removePrefix((new Locator(locator, 'css')).toXPath()));
+    return new Locator({ xpath });
+  }
+
+  withDescendant(locator) {
+    const xpath = sprintf('%s//descendant::%s', this.toXPath(), removePrefix((new Locator(locator, 'css')).toXPath()));
     return new Locator({ xpath });
   }
 
diff --git a/lib/plugin/puppeteerCoverage.js b/lib/plugin/puppeteerCoverage.js
new file mode 100644
index 00000000..d0126a32
--- /dev/null
+++ b/lib/plugin/puppeteerCoverage.js
@@ -0,0 +1,155 @@
+const Container = require('../container');
+const recorder = require('../recorder');
+const event = require('../event');
+const output = require('../output');
+const fs = require('fs');
+const path = require('path');
+const { clearString } = require('../utils');
+const debug = require('debug')('codeceptjs:plugin:puppeteerCoverage');
+
+const defaultConfig = {
+  coverageDir: 'output/coverage',
+  uniqueFileName: true,
+};
+
+const supportedHelpers = ['Puppeteer'];
+
+/**
+ * Builds the filename based on the test title
+ */
+function buildFileName(test, uniqueFileName) {
+  let fileName = clearString(test.title);
+
+  // This prevent data driven to be included in the failed screenshot file name
+  if (fileName.indexOf('{') !== -1) {
+    fileName = fileName.substr(0, fileName.indexOf('{') - 3).trim();
+  }
+
+  if (test.ctx && test.ctx.test && test.ctx.test.type === 'hook') {
+    fileName = clearString(`${test.title}_${test.ctx.test.title}`);
+  }
+
+  if (uniqueFileName) {
+    const uuid =
+      test.uuid ||
+      test.ctx.test.uuid ||
+      Math.floor(new Date().getTime() / 1000);
+    fileName = `${fileName.substring(0, 10)}_${uuid}.coverage.json`;
+  } else {
+    fileName = `${fileName}.coverage.json`;
+  }
+
+  return fileName;
+}
+
+/**
+ * Dumps puppeteers code coverage after every test.
+ *
+ * ##### Configuration
+ *
+ * Configuration can either be taken from a corresponding helper (deprecated) or a from plugin config (recommended).
+ *
+ * ```js
+ * "plugins": {
+ *    "puppeteerCoverage": {
+ *      "enabled": true
+ *    }
+ * }
+ * ```
+ *
+ * Possible config options:
+ *
+ * * `outputDir`: directory to dump coverage files
+ * * `uniqueFileName`: generate a unique filename by adding uuid
+ *
+ * Notes:
+ *  First of all, YMMV!
+ *  To work, you need the client javascript code to be NOT uglified.  They need to be built in "development" mode.
+ *  And the end of your tests, you'll get a directory full of coverage per test run.  Now what?
+ *  You'll need to convert the coverage code to something istanbul can read.  Good news is someone wrote the code
+ *  for you (see puppeteer-to-istanbul link below).  Then using istanbul you need to combine the converted
+ *  coverage and create a report.  Good luck!
+ *
+ *  Links:
+ *  * https://github.com/GoogleChrome/puppeteer/blob/v1.12.2/docs/api.md#class-coverage
+ *  * https://github.com/istanbuljs/puppeteer-to-istanbul
+ *  * https://github.com/gotwarlost/istanbul
+ */
+module.exports = function (config) {
+  const helpers = Container.helpers();
+  let coverageRunning = false;
+  let helper;
+
+  for (const helperName of supportedHelpers) {
+    if (Object.keys(helpers).indexOf(helperName) > -1) {
+      helper = helpers[helperName];
+    }
+  }
+
+  if (!helper) {
+    console.error('Coverage is only supported in Puppeteer');
+    return; // no helpers for screenshot
+  }
+
+  const options = Object.assign(defaultConfig, helper.options, config);
+
+  event.dispatcher.on(event.all.before, async (suite) => {
+    output.debug('*** Collecting coverage for tests ****');
+  });
+
+  /**
+   *  Hack!  we're going to try to "start" coverage before each step because this is
+   *  when the browser is already up and is ready to start coverage.
+   */
+  event.dispatcher.on(event.step.before, async (step) => {
+    recorder.add(
+      'starting coverage',
+      async () => {
+        try {
+          if (!coverageRunning) {
+            debug('--> starting coverage <--');
+            coverageRunning = true;
+            await helper.page.coverage.startJSCoverage();
+          }
+        } catch (err) {
+          console.error(err);
+        }
+      },
+      true,
+    );
+  });
+
+  /**
+   * Save puppeteer coverage data after every test run
+   */
+  event.dispatcher.on(event.test.after, async (test) => {
+    recorder.add(
+      'saving coverage',
+      async () => {
+        try {
+          if (coverageRunning) {
+            debug('--> stopping coverage <--');
+            coverageRunning = false;
+            const coverage = await helper.page.coverage.stopJSCoverage();
+
+            const coverageDir = path.resolve(
+              process.cwd(),
+              options.coverageDir,
+            );
+            fs.mkdirSync(coverageDir, { recursive: true });
+
+            const coveragePath = path.resolve(
+              coverageDir,
+              buildFileName(test, options.uniqueFileName),
+            );
+            output.print(`writing ${coveragePath}`);
+            fs.writeFileSync(coveragePath, JSON.stringify(coverage));
+          }
+        } catch (err) {
+          console.error(err);
+        }
+      },
+      true,
+    );
+  });
+};
diff --git a/translations/index.js b/translations/index.js
index 405658d6..f5285e69 100644
--- a/translations/index.js
+++ b/translations/index.js
@@ -4,3 +4,4 @@ exports['it-IT'] = require('./it-IT');
 exports['pl-PL'] = require('./pl-PL');
 exports['zh-CN'] = require('./zh-CN');
 exports['zh-TW'] = require('./zh-TW');
+exports['ja-JP'] = require('./ja-JP');
diff --git a/translations/ja-JP.js b/translations/ja-JP.js
new file mode 100644
index 00000000..c8fd1005
--- /dev/null
+++ b/translations/ja-JP.js
@@ -0,0 +1,55 @@
+module.exports = {
+  I: '私は',
+  actions: {
+    amOutsideAngularApp: 'Angularの外に出る',
+    amInsideAngularApp: 'Angularの中に入る',
+    waitForElement: '要素を待つ',
+    waitForClickable: 'クリック可能になるまで待つ',
+    waitForVisible: '要素が見えるようになるまで待つ',
+    waitForText: 'テキストが表示されるまで待つ',
+    moveTo: '移動する',
+    refresh: '更新する',
+    haveModule: 'モジュールを持っている',
+    resetModule: 'モジュールをリセットする',
+    amOnPage: 'ページを移動する',
+    click: 'クリックする',
+    doubleClick: 'ダブルクリックする',
+    see: 'テキストがあるか確認する',
+    dontSee: 'テキストがないことを確認する',
+    selectOption: 'オプションを選ぶ',
+    fillField: 'フィールドに入力する',
+    pressKey: 'キー入力する',
+    triggerMouseEvent: 'マウスイベントを発火させる',
+    attachFile: 'ファイルを添付する',
+    seeInField: 'フィールドに文字が入っているか確認する',
+    dontSeeInField: 'フィールドに文字が入っていないか確認する',
+    appendField: 'フィールドに文字を追加する',
+    checkOption: 'オプションをチェックする',
+    seeCheckboxIsChecked: 'チェックされているか確認する',
+    dontSeeCheckboxIsChecked: 'チェックされていないことを確認する',
+    grabTextFrom: 'テキストを取得する',
+    grabValueFrom: '入力値を取得する',
+    grabAttributeFrom: '要素を取得する',
+    seeInTitle: 'タイトルに文字が含まれるか確認する',
+    dontSeeInTitle: 'タイトルに文字が含まれないことを確認する',
+    grabTitle: 'タイトルを取得する',
+    seeElement: '要素があるか確認する',
+    dontSeeElement: '要素がないことを確認する',
+    seeInSource: 'ソースにあるか確認する',
+    dontSeeInSource: 'ソースにないことを確認する',
+    executeScript: 'スクリプトを実行する',
+    executeAsyncScript: '非同期スクリプトを実行する',
+    seeInCurrentUrl: 'URLに含まれるか確認する',
+    dontSeeInCurrentUrl: 'URLに含まれないことを確認する',
+    seeCurrentUrlEquals: 'URLが等しいか確認する',
+    dontSeeCurrentUrlEquals: 'URLが等しくないことを確認する',
+    saveScreenshot: 'スクリーンショットを保存する',
+    setCookie: 'Cookieをセットする',
+    clearCookie: 'Cookieをクリアする',
+    seeCookie: 'Cookieに含まれることを確認する',
+    dontSeeCookie: 'Cookieに含まれないことを確認する',
+    grabCookie: 'Cookieを取得する',
+    resizeWindow: 'ウィンドウをリサイズする',
+    wait: '待つ',
+  },
+};


Test patch:
diff --git a/test/data/sandbox/page_slider.html b/test/data/app/view/form/page_slider.php
similarity index 100%
rename from test/data/sandbox/page_slider.html
rename to test/data/app/view/form/page_slider.php
diff --git a/test/helper/Puppeteer_test.js b/test/helper/Puppeteer_test.js
index e591f9dc..66169d0b 100644
--- a/test/helper/Puppeteer_test.js
+++ b/test/helper/Puppeteer_test.js
@@ -550,7 +550,7 @@ describe('Puppeteer', function () {
 
   describe('#dragSlider', () => {
     it('should drag scrubber to given position', async () => {
-      await I.amOnPage(`file://${path.resolve(__dirname, '../data/sandbox/page_slider.html')}`);
+      await I.amOnPage('/form/page_slider');
       await I.seeElementInDOM('#slidecontainer input');
       const before = await I.grabValueFrom('#slidecontainer input');
       await I.dragSlider('#slidecontainer input', 20);
diff --git a/test/helper/WebDriver_test.js b/test/helper/WebDriver_test.js
index 2f5d6be9..2faa0ff6 100644
--- a/test/helper/WebDriver_test.js
+++ b/test/helper/WebDriver_test.js
@@ -638,4 +638,16 @@ describe('WebDriver', function () {
     it('should attach to invisible input element', () => wd.amOnPage('/form/file')
       .then(() => wd.attachFile('hidden', '/app/avatar.jpg')));
   });
+
+
+  describe('#dragSlider', () => {
+    it('should drag scrubber to given position', async () => {
+      await wd.amOnPage('/form/page_slider');
+      await wd.seeElementInDOM('#slidecontainer input');
+      const before = await wd.grabValueFrom('#slidecontainer input');
+      await wd.dragSlider('#slidecontainer input', 20);
+      const after = await wd.grabValueFrom('#slidecontainer input');
+      assert.notEqual(before, after);
+    });
+  });
 });
diff --git a/test/unit/locator_test.js b/test/unit/locator_test.js
index b09fdeda..567abe88 100644
--- a/test/unit/locator_test.js
+++ b/test/unit/locator_test.js
@@ -44,6 +44,12 @@ const xml = `<body>
         <label for="remember" class="optional">Remember Me</label>
       </div>
     </div>
+    <div class="form-field">
+      <input name="name0" label="Выберите услугу" type="text" value=""/>
+    </div>
+    <div class="form-field">
+      <input name="name1" label="Выберите услугу" type="text" value=""/>
+    </div>
   </fieldset>
   <label>Hello<a href="#">Please click</a></label>
   </div>
@@ -106,6 +112,15 @@ describe('Locator', () => {
     expect(nodes[0].firstChild.data).to.eql('Please click', l.toXPath());
   });
 
+
+  it('should select child element by name', () => {
+    const l = Locator.build('.form-field')
+      .withDescendant(Locator.build('//input[@name="name1"]'));
+    const nodes = xpath.select(l.toXPath(), doc);
+
+    expect(nodes).to.have.length(1, l.toXPath());
+  });
+
   it('should select element by siblings', () => {
     const l = Locator.build('//table')
       .withChild('td')