Repository: Codeception/CodeceptJS
Pull request #1574 (2019-03-22 09:15:14)

[Appium helper] Improvements on wdiov5
This PR aims to fix/improve the following issues with appium-wdiov5:
- https://github.com/Codeception/CodeceptJS/issues/1538 -> due to those such methods like `swipeUp`, `swipeDown`, `swipeLeft`, `swipeRight` are replaced by `touchAction`
- https://github.com/Codeception/CodeceptJS/issues/1535 -> call the appium api endpoint to remove app.
- https://github.com/Codeception/CodeceptJS/issues/1528 -> due to those such methods like `swipeUp`, `swipeDown`, `swipeLeft`, `swipeRight` are replaced by `touchAction`
- https://github.com/Codeception/CodeceptJS/issues/1527 -> call the appium api endpoint to set the orientation.

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


```diff

Clones added
============
- test/helper/Appium_test.js  6
         
```


See the complete overview on [Codacy](https://app.codacy.com/app/DavertMik/CodeceptJS/pullRequest?prid=3309887&bid=11954513)
        
codacy-bot: 
![Codacy](https://app.codacy.com/assets/images/favicon.png) Here is an overview of what got changed by this pull request:


```diff

Clones added
============
- test/helper/Appium_test.js  6
         
```


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

Patch:
diff --git a/bin/codecept.js b/bin/codecept.js
index ef5894a9..5586e171 100755
--- a/bin/codecept.js
+++ b/bin/codecept.js
@@ -121,6 +121,8 @@ program.command('run-multiple [suites...]')
   .option('-c, --config [file]', 'configuration file to be used')
   .option('--profile [value]', 'configuration profile to be used')
   .option('--all', 'run all suites')
+  .option('--features', 'run only *.feature files and skip tests')
+  .option('--tests', 'run only JS test files and skip features')
   .option('-g, --grep <pattern>', 'only run tests matching <pattern>')
   .option('-f, --fgrep <string>', 'only run tests containing <string>')
   .option('-i, --invert', 'inverts --grep and --fgrep matches')
diff --git a/docs/configuration.md b/docs/configuration.md
index f03a59f8..7ec0bf07 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -11,7 +11,7 @@ After running `codeceptjs init` it should be saved in test root.
 Here is an overview of available options with their defaults:
 
 * **tests**: `"./*_test.js"` - pattern to locate tests
-* **grep**: - patern to filter tests by name
+* **grep**: - pattern to filter tests by name
 * **include**: `{}` - actors and page objects to be registered in DI container and included in tests. Accepts objects and module `require` paths
 * **timeout**: `10000` - default tests timeout
 * **output**: `"./output"` - where to store failure screenshots, etc
diff --git a/docs/helpers/Appium.md b/docs/helpers/Appium.md
index e67c0ed7..3398b964 100644
--- a/docs/helpers/Appium.md
+++ b/docs/helpers/Appium.md
@@ -365,6 +365,37 @@ I.openNotifications();
 
 Appium: support only Android
 
+### performSwipe
+
+Perform a swipe on the screen.
+
+```js
+I.performswipe(100,200);
+```
+
+#### Parameters
+
+-   `from`  
+-   `to`  
+
+Appium: support Android and iOS
+
+### performTouchAction
+
+Perform a certain touch action on the screen such as swipeDown, swipeUp, swipeLeft, swipeRight.
+
+```js
+I.swipeDown('swipeDown'); // perform swipe down on the screen
+I.swipeDown('swipeUp'); // perform swipe up on the screen
+I.swipeDown('swipeLeft'); // perform swipe left on the screen
+I.swipeDown('swipeRight'); // perform swipe right on the screen
+```
+
+#### Parameters
+
+-   `action`  
+-   `percentage`  (optional), from 0 - 1, 1 by defaultAppium: support Android and iOS
+
 ### pullFile
 
 Pulls a file from the device.
@@ -392,6 +423,7 @@ I.removeApp('appName', 'com.example.android.apis');
 
 #### Parameters
 
+-   `appId`  
 -   `bundleId`  String  ID of bundleAppium: support only Android
 
 ### rotate
diff --git a/docs/reports.md b/docs/reports.md
index b7395f57..c53509c9 100644
--- a/docs/reports.md
+++ b/docs/reports.md
@@ -291,7 +291,8 @@ Configure mocha-multi with reports that you want:
         "stdout": "./output/console.log",
         "options": {
           "mochaFile": "./output/result.xml"
-        }
+        },
+        "attachments": true //add screenshot for a failed test
       }
     }
   }
diff --git a/docs/webapi/fillField.mustache b/docs/webapi/fillField.mustache
index 906bf950..cf5ed312 100644
--- a/docs/webapi/fillField.mustache
+++ b/docs/webapi/fillField.mustache
@@ -5,11 +5,11 @@ Field is located by name, label, CSS, or XPath.
 // by label
 I.fillField('Email', 'hello@world.com');
 // by name
-I.fillField('password', '123456');
+I.fillField('password', secret('123456'));
 // by CSS
 I.fillField('form#login input[name=username]', 'John');
 // or by strict locator
 I.fillField({css: 'form#login input[name=username]'}, 'John');
 ```
 @param field located by label|name|CSS|XPath|strict locator.
-@param value text value to fill.
\ No newline at end of file
+@param value text value to fill.
diff --git a/examples/codecept.conf.js b/examples/codecept.conf.js
new file mode 100644
index 00000000..5f30cdf7
--- /dev/null
+++ b/examples/codecept.conf.js
@@ -0,0 +1,65 @@
+exports.config = {
+  output: './output',
+  helpers: {
+    WebDriver: {
+      url: 'http://localhost',
+      browser: 'chrome',
+      restart: false,
+      windowSize: '1600x1200',
+    },
+    REST: {},
+  },
+  include: {
+    I: './custom_steps.js',
+    Smth: './pages/Smth.js',
+    loginPage: './pages/Login.js',
+    signinFragment: './fragments/Signin.js',
+  },
+  mocha: {
+    reporterOptions: {
+      mochaFile: './output/result.xml',
+    },
+  },
+  bootstrap: './bootstrap.js',
+  teardown: null,
+  hooks: [],
+  gherkin: {
+    features: './features/*.feature',
+    steps: [
+      './step_definitions/steps.js',
+    ],
+  },
+  plugins: {
+    allure: {
+      enabled: false,
+    },
+    wdio: {
+      enabled: false,
+      services: [
+        'selenium-standalone',
+      ],
+    },
+    stepByStepReport: {},
+    autoDelay: {
+      enabled: false,
+    },
+    retryFailedStep: {
+      enabled: true,
+    },
+  },
+  tests: './*_test.js',
+  timeout: 10000,
+  multiple: {
+    parallel: {
+      chunks: 2,
+    },
+    default: {
+      grep: 'signin',
+      browsers: [
+        'chrome',
+        'firefox',
+      ],
+    },
+  },
+  name: 'tests',
+};
diff --git a/examples/codecept.json b/examples/codecept.json
deleted file mode 100644
index 7b7db54e..00000000
--- a/examples/codecept.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
-  "output": "./output",
-  "helpers": {
-    "WebDriverIO": {
-      "url": "http://localhost",
-      "browser": "chrome",
-      "restart": true,
-      "windowSize": "1600x1200"
-    },
-    "REST": {}
-  },
-  "include": {
-    "I": "./custom_steps.js",
-    "Smth": "./pages/Smth.js",
-    "loginPage": "./pages/Login.js",
-    "signinFragment": "./fragments/Signin.js"
-  },
-  "mocha": {
-    "reporterOptions": {
-      "mochaFile": "./output/result.xml"
-    }
-  },
-  "bootstrap": "./bootstrap.js",
-  "teardown": null,
-  "hooks": [],
-  "gherkin": {
-    "features": "./features/*.feature",
-    "steps": [
-      "./step_definitions/steps.js"
-    ]
-  },
-  "plugins": {
-    "allure": {
-      "enabled": false
-    },
-    "stepByStepReport": {},
-    "autoDelay": {
-      "enabled": true
-    },
-    "retryFailedStep": {
-      "enabled": true
-    }
-  },
-  "tests": "./*_test.js",
-  "timeout": 10000,
-  "multiple": {
-    "parallel": {
-      "chunks": 2
-    },
-    "default": {
-      "grep": "signin",
-      "browsers": [
-        "chrome",
-        "firefox"
-      ]
-    }
-  },
-  "name": "tests"
-}
\ No newline at end of file
diff --git a/lib/codecept.js b/lib/codecept.js
index b24f85b0..8990836a 100644
--- a/lib/codecept.js
+++ b/lib/codecept.js
@@ -73,6 +73,7 @@ class Codecept {
       global.session = require('./session');
       global.DataTable = require('./data/table');
       global.locate = locator => require('./locator').build(locator);
+      global.secret = require('./secret').secret;
       global.codeceptjs = require('./index'); // load all objects
 
       // BDD
@@ -154,7 +155,9 @@ class Codecept {
     const mocha = container.mocha();
     mocha.files = this.testFiles;
     if (test) {
-      test = fsPath.join(global.codecept_dir, test);
+      if (!fsPath.isAbsolute(test)) {
+        test = fsPath.join(global.codecept_dir, test);
+      }
       mocha.files = mocha.files.filter(t => fsPath.basename(t, '.js') === test || t === test);
     }
     event.emit(event.all.before, this);
diff --git a/lib/command/generate.js b/lib/command/generate.js
index acca2b3d..59804c5f 100644
--- a/lib/command/generate.js
+++ b/lib/command/generate.js
@@ -31,7 +31,7 @@ module.exports.test = function (genPath) {
   inquirer.prompt([
     {
       type: 'input',
-      message: 'Filename of a test',
+      message: 'Filename of the test',
       name: 'filename',
     },
     {
diff --git a/lib/command/init.js b/lib/command/init.js
index a6f97467..ce6889ae 100644
--- a/lib/command/init.js
+++ b/lib/command/init.js
@@ -90,15 +90,9 @@ module.exports = function (initPath) {
     {
       name: 'steps',
       type: 'confirm',
-      message: 'Would you like to extend I object with custom steps?',
+      message: 'Would you like to extend the "I" object with custom steps?',
       default: true,
     },
-    {
-      name: 'translation',
-      type: 'list',
-      message: 'Do you want to choose localization for tests?',
-      choices: translations,
-    },
     {
       name: 'steps_file',
       type: 'input',
@@ -108,6 +102,12 @@ module.exports = function (initPath) {
         return answers.steps;
       },
     },
+    {
+      name: 'translation',
+      type: 'list',
+      message: 'Do you want to choose localization for tests?',
+      choices: translations,
+    },
   ]).then((result) => {
     const config = defaultConfig;
     config.name = testsPath.split(path.sep).pop();
@@ -167,12 +167,12 @@ module.exports = function (initPath) {
       if (config.output) {
         if (!fileExists(config.output)) {
           mkdirp.sync(path.join(testsPath, config.output));
-          success('Directory for temporary output files created at `_output`');
+          success(`Directory for temporary output files created at '${config.output}'`);
         } else {
           print(`Directory for temporary output files is already created at '${config.output}'`);
         }
       }
-      success('Almost done! Create your first test by executing `codeceptjs gt` (generate test) command');
+      success('Almost done! Create your first test by executing `npx codeceptjs gt` (generate test) command');
 
       if (packages) {
         print('\n--');
diff --git a/lib/command/run-multiple.js b/lib/command/run-multiple.js
index 142536af..c9d3fbd0 100644
--- a/lib/command/run-multiple.js
+++ b/lib/command/run-multiple.js
@@ -66,8 +66,18 @@ module.exports = function (selectedRuns, options) {
 
   if (options.config) { // update paths to config path
     config.tests = path.resolve(testRoot, config.tests);
+    if (config.gherkin && config.gherkin.features) {
+      config.gherkin.features = path.resolve(testRoot, config.gherkin.features);
+    }
+  }
+
+  if (options.features) {
+    config.tests = '';
   }
 
+  if (options.tests && config.gherkin) {
+    config.gherkin.features = '';
+  }
 
   const childProcessesPromise = new Promise((resolve, reject) => {
     processesDone = resolve;
@@ -127,6 +137,11 @@ function executeRun(runName, runConfig) {
     overriddenConfig.tests = runConfig.tests;
   }
 
+  if (overriddenConfig.gherkin && runConfig.gherkin && runConfig.gherkin.features) {
+    overriddenConfig.gherkin.features = runConfig.gherkin.features;
+  }
+
+
   // override grep param and collect all params
   const params = ['run',
     '--child', `${runId++}.${runName}:${browserName}`,
diff --git a/lib/command/run-multiple/chunk.js b/lib/command/run-multiple/chunk.js
index 30b1f9cf..5ac7d0a0 100644
--- a/lib/command/run-multiple/chunk.js
+++ b/lib/command/run-multiple/chunk.js
@@ -49,15 +49,24 @@ const grepFile = (file, grep) => {
   return !!pattern.exec(contents);
 };
 
+const mapFileFormats = (files) => {
+  return {
+    gherkin: files.filter(file => file.match(/\.feature$/)),
+    js: files.filter(file => file.match(/\.js$/)),
+  };
+};
+
 /**
  * Creates a list of chunks incl. configuration by either dividing a list of scenario
  * files by the passed number or executing a usder deifned function to perform
  * the splitting.
  */
-const createChunks = (config, pattern) => {
-  const files = findFiles(pattern).filter((file) => {
-    return config.grep ? grepFile(file, config.grep) : true;
-  });
+const createChunks = (config, patterns = []) => {
+  const files = patterns.filter(pattern => !!pattern).map((pattern) => {
+    return findFiles(pattern).filter((file) => {
+      return config.grep ? grepFile(file, config.grep) : true;
+    });
+  }).reduce((acc, val) => acc.concat(val), []);
 
   let chunks = [];
   if (typeof config.chunks === 'function') {
@@ -71,8 +80,10 @@ const createChunks = (config, pattern) => {
   const chunkConfig = { ...config };
   delete chunkConfig.chunks;
 
+
   return chunks.map((chunkFiles) => {
-    return { ...chunkConfig, tests: flattenFiles(chunkFiles) };
+    const { js, gherkin } = mapFileFormats(chunkFiles);
+    return { ...chunkConfig, tests: flattenFiles(js), gherkin: { features: flattenFiles(gherkin) } };
   });
 };
 
diff --git a/lib/command/run-multiple/collection.js b/lib/command/run-multiple/collection.js
index 1b0ef603..aa7a57b5 100644
--- a/lib/command/run-multiple/collection.js
+++ b/lib/command/run-multiple/collection.js
@@ -69,13 +69,18 @@ class Collection {
     this.runs.forEach((run) => {
       const runName = run.getName();
       const runConfig = run.getConfig();
-      const pattern = run.tests || config.tests;
+      const patterns = [];
+      patterns.push(run.tests || config.tests);
 
-      if (!runConfig.chunks || !pattern) {
+      if (config.gherkin && config.gherkin.features) {
+        patterns.push(config.gherkin.features);
+      }
+
+      if (!runConfig.chunks || !patterns.length) {
         return;
       }
 
-      createChunks(runConfig, pattern).forEach((runChunkConfig, index) => {
+      createChunks(runConfig, patterns).forEach((runChunkConfig, index) => {
         const run = createRun(`${runName}:chunk${index + 1}`, runChunkConfig);
         run.setOriginalName(runName);
         this.addRun(run);
@@ -135,8 +140,6 @@ class Collection {
    */
   filterSelectedBrowsers(selectedRuns, config) {
     selectedRuns.forEach((selectedRun) => {
-      const [selectedRunName, selectedRunBrowserName] = selectedRun.split(':');
-
       selectedRuns.forEach((selectedRun) => {
         const [selectedRunName, selectedRunBrowserName] = selectedRun.split(':');
         this.runs.forEach((run) => {
diff --git a/lib/helper/Appium.js b/lib/helper/Appium.js
index 5980d554..74660a53 100644
--- a/lib/helper/Appium.js
+++ b/lib/helper/Appium.js
@@ -1,4 +1,6 @@
 let webdriverio;
+let wdioV4;
+let SCREEN_SIZE;
 const Webdriver = require('./WebDriver');
 const AssertionFailedError = require('../assert/error');
 const truth = require('../assert/truth').truth;
@@ -9,9 +11,28 @@ const Locator = require('../locator');
 const ConnectionRefused = require('./errors/ConnectionRefused');
 const ElementNotFound = require('./errors/ElementNotFound');
 
+const axios = requireg('axios');
 
 const mobileRoot = '//*';
 const webRoot = 'body';
+const SWIPE_DIRECTION = {
+  down: {
+    start: { x: 50, y: 15 },
+    end: { x: 50, y: 85 },
+  },
+  left: {
+    start: { x: 95, y: 50 },
+    end: { x: 5, y: 50 },
+  },
+  right: {
+    start: { x: 5, y: 50 },
+    end: { x: 95, y: 50 },
+  },
+  up: {
+    start: { x: 50, y: 85 },
+    end: { x: 50, y: 15 },
+  },
+};
 
 
 /**
@@ -91,6 +112,7 @@ class Appium extends Webdriver {
     this.isRunning = false;
 
     webdriverio = requireg('webdriverio');
+    (!webdriverio.VERSION || webdriverio.VERSION.indexOf('4') !== 0) ? wdioV4 = false : wdioV4 = true;
   }
 
   _validateConfig(config) {
@@ -244,6 +266,14 @@ class Appium extends Webdriver {
     return this.switchToNative();
   }
 
+  _buildAppiumEndpoint() {
+    const {
+      protocol, port, hostname, path,
+    } = this.browser.options;
+    // Build path to Appium REST API endpoint
+    return `${protocol}://${hostname}:${port}${path}`;
+  }
+
 
   /**
    * Execute code only on iOS
@@ -433,9 +463,18 @@ class Appium extends Webdriver {
    *
    * Appium: support only Android
    */
-  async removeApp(bundleId) {
+  async removeApp(appId, bundleId) {
     onlyForApps.call(this, 'Android');
-    return this.browser.removeApp([], [bundleId]);
+
+    if (wdioV4) {
+      return this.browser.removeApp(bundleId);
+    }
+
+    return axios({
+      method: 'post',
+      url: `${this._buildAppiumEndpoint()}/session/${this.browser.sessionId}/appium/device/remove_app`,
+      data: { appId, bundleId },
+    });
   }
 
   /**
@@ -497,8 +536,20 @@ class Appium extends Webdriver {
    */
   async seeOrientationIs(orientation) {
     onlyForApps.call(this);
-    const res = await this.browser.orientation();
-    return truth('orientation', `to be ${orientation}`).assert(res === orientation);
+    let currentOrientation;
+
+    if (wdioV4) {
+      const res = await this.browser.orientation();
+      currentOrientation = res;
+    }
+
+    const res = await axios({
+      method: 'get',
+      url: `${this._buildAppiumEndpoint()}/session/${this.browser.sessionId}/orientation`,
+    });
+
+    currentOrientation = res.data.value;
+    return truth('orientation', `to be ${orientation}`).assert(currentOrientation === orientation);
   }
 
   /**
@@ -515,7 +566,15 @@ class Appium extends Webdriver {
    */
   async setOrientation(orientation) {
     onlyForApps.call(this);
-    return this.browser.setOrientation(orientation);
+    if (wdioV4) {
+      return this.browser.setOrientation(orientation);
+    }
+
+    return axios({
+      method: 'post',
+      url: `${this._buildAppiumEndpoint()}/session/${this.browser.sessionId}/orientation`,
+      data: { orientation },
+    });
   }
 
   /**
@@ -761,7 +820,10 @@ class Appium extends Webdriver {
    */
   async sendDeviceKeyEvent(keyValue) {
     onlyForApps.call(this, 'Android');
-    return this.browser.sendKeyEvent(keyValue);
+    if (wdioV4) {
+      return this.browser.sendKeyEvent(keyValue);
+    }
+    return this.browser.pressKeyCode(keyValue);
   }
 
   /**
@@ -816,6 +878,30 @@ class Appium extends Webdriver {
     return this.makeTouchAction(locator, 'tap');
   }
 
+  _calculateXY({ x, y }, percentage) {
+    return {
+      x: x * percentage,
+      y: y * percentage,
+    };
+  }
+
+  _getDeviceScreenCoordinates(screenSize, coordinates) {
+    return {
+      x: Math.round(screenSize.width * (coordinates.x / 100)),
+      y: Math.round(screenSize.height * (coordinates.y / 100)),
+    };
+  }
+
+  async _swipeOnPercentage(from, to) {
+    SCREEN_SIZE = SCREEN_SIZE || await this.browser.getWindowRect();
+    const pressOptions = this._getDeviceScreenCoordinates(SCREEN_SIZE, from);
+    const moveToScreenCoordinates = this._getDeviceScreenCoordinates(SCREEN_SIZE, to);
+    await this.performSwipe(
+      pressOptions,
+      moveToScreenCoordinates,
+    );
+  }
+
   /**
    * Perform a swipe on the screen or an element.
    *
@@ -840,6 +926,34 @@ class Appium extends Webdriver {
     return this.browser.touchFlick(xoffset, yoffset, res.ELEMENT, speed);
   }
 
+  /**
+   * Perform a swipe on the screen.
+   *
+   * ```js
+   * I.performswipe(100,200);
+   * ```
+   *
+   * @param from
+   * @param to
+   *
+   * Appium: support Android and iOS
+   */
+  async performSwipe(from, to) {
+    await this.browser.touchPerform([{
+      action: 'press',
+      options: from,
+    }, {
+      action: 'wait',
+      options: { ms: 1000 },
+    }, {
+      action: 'moveTo',
+      options: to,
+    }, {
+      action: 'release',
+    }]);
+    await this.browser.pause(1000);
+  }
+
   /**
    * Perform a swipe down on an element.
    *
@@ -867,6 +981,50 @@ class Appium extends Webdriver {
     return this.swipe(locator, 0, yoffset, speed);
   }
 
+  /**
+   * Perform a certain touch action on the screen such as swipeDown, swipeUp, swipeLeft, swipeRight.
+   *
+   * ```js
+   * I.swipeDown('swipeDown'); // perform swipe down on the screen
+   * I.swipeDown('swipeUp'); // perform swipe up on the screen
+   * I.swipeDown('swipeLeft'); // perform swipe left on the screen
+   * I.swipeDown('swipeRight'); // perform swipe right on the screen
+   * ```
+   *
+   * @param action
+   * @param percentage (optional), from 0 - 1, 1 by default
+   *
+   * Appium: support Android and iOS
+   */
+  async performTouchAction(action, percentage = 1) {
+    switch (action) {
+      case 'swipeDown':
+        this._swipeOnPercentage(
+          this._calculateXY(SWIPE_DIRECTION.down.start, percentage),
+          this._calculateXY(SWIPE_DIRECTION.down.end, percentage),
+        );
+        break;
+      case 'swipeUp':
+        this._swipeOnPercentage(
+          this._calculateXY(SWIPE_DIRECTION.up.start, percentage),
+          this._calculateXY(SWIPE_DIRECTION.up.end, percentage),
+        );
+        break;
+      case 'swipeLeft':
+        this._swipeOnPercentage(
+          this._calculateXY(SWIPE_DIRECTION.left.start, percentage),
+          this._calculateXY(SWIPE_DIRECTION.left.end, percentage),
+        );
+        break;
+      case 'swipeRight':
+        this._swipeOnPercentage(
+          this._calculateXY(SWIPE_DIRECTION.right.start, percentage),
+          this._calculateXY(SWIPE_DIRECTION.right.end, percentage),
+        );
+        break;
+    }
+  }
+
   /**
    * Perform a swipe left on an element.
    *
@@ -1215,6 +1373,7 @@ class Appium extends Webdriver {
    *
    */
   async fillField(field, value) {
+    value = value.toString();
     if (this.isWeb) return super.fillField(field, value);
     return super.fillField(parseLocator.call(this, field), value);
   }
diff --git a/lib/helper/Nightmare.js b/lib/helper/Nightmare.js
index 428c5e72..3ea50d2d 100644
--- a/lib/helper/Nightmare.js
+++ b/lib/helper/Nightmare.js
@@ -675,7 +675,7 @@ class Nightmare extends Helper {
   async fillField(field, value) {
     const el = await findField.call(this, field);
     assertElementExists(el, field, 'Field');
-    return this.browser.enterText(el, value, true)
+    return this.browser.enterText(el, value.toString(), true)
       .wait(this.options.waitForAction);
   }
 
diff --git a/lib/helper/Protractor.js b/lib/helper/Protractor.js
index 97cf602a..d5a91350 100644
--- a/lib/helper/Protractor.js
+++ b/lib/helper/Protractor.js
@@ -566,7 +566,7 @@ class Protractor extends Helper {
   async fillField(field, value) {
     const els = await findFields(this.browser, field);
     await els[0].clear();
-    return els[0].sendKeys(value);
+    return els[0].sendKeys(value.toString());
   }
 
   /**
diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js
index 6b8b6d1c..d8d6a642 100644
--- a/lib/helper/Puppeteer.js
+++ b/lib/helper/Puppeteer.js
@@ -972,7 +972,7 @@ class Puppeteer extends Helper {
     } else if (editable) {
       await this._evaluateHandeInContext(el => el.innerHTML = '', el);
     }
-    await el.type(value, { delay: 10 });
+    await el.type(value.toString(), { delay: 10 });
     return this._waitForAction();
   }
 
diff --git a/lib/helper/WebDriver.js b/lib/helper/WebDriver.js
index 1bc6747c..f5e0c6bf 100644
--- a/lib/helper/WebDriver.js
+++ b/lib/helper/WebDriver.js
@@ -677,7 +677,7 @@ class WebDriver extends Helper {
     const res = await findFields.call(this, field);
     assertElementExists(res, field, 'Field');
     const elem = usingFirstElement(res);
-    return elem.setValue(value);
+    return elem.setValue(value.toString());
   }
 
   /**
diff --git a/lib/helper/WebDriverIO.js b/lib/helper/WebDriverIO.js
index 1d10b45f..0e5e5658 100644
--- a/lib/helper/WebDriverIO.js
+++ b/lib/helper/WebDriverIO.js
@@ -601,7 +601,7 @@ class WebDriverIO extends Helper {
     const res = await findFields.call(this, field);
     assertElementExists(res, field, 'Field');
     const elem = res.value[0];
-    return this.browser.elementIdClear(elem.ELEMENT).elementIdValue(elem.ELEMENT, value);
+    return this.browser.elementIdClear(elem.ELEMENT).elementIdValue(elem.ELEMENT, value.toString());
   }
 
   /**
@@ -2144,4 +2144,3 @@ try {
 } catch (err) {
   // not installed, fine
 }
-
diff --git a/lib/plugin/autoLogin.js b/lib/plugin/autoLogin.js
index e21c20a2..96886c92 100644
--- a/lib/plugin/autoLogin.js
+++ b/lib/plugin/autoLogin.js
@@ -202,7 +202,7 @@ module.exports = function (config) {
     const cookies = store[`${name}_session`];
 
     const loginAndSave = async () => {
-      userSession.login(I);
+      await userSession.login(I);
       store.debugMode = true;
       const cookies = await userSession.fetch(I);
       if (config.saveToFile) {
@@ -218,8 +218,8 @@ module.exports = function (config) {
     store.debugMode = true;
 
     recorder.session.start('check login');
-    userSession.restore(I, cookies);
-    userSession.check(I);
+    await userSession.restore(I, cookies);
+    await userSession.check(I);
     recorder.session.catch((err) => {
       debug(`Failed auto login for ${name} due to ${err}`);
       debug('Logging in again');
diff --git a/lib/plugin/screenshotOnFail.js b/lib/plugin/screenshotOnFail.js
index cf833981..c6e4866c 100644
--- a/lib/plugin/screenshotOnFail.js
+++ b/lib/plugin/screenshotOnFail.js
@@ -101,6 +101,11 @@ module.exports = function (config) {
           }
         }
         await helper.saveScreenshot(fileName, options.fullPageScreenshots);
+
+        if (Container.mocha().options.reporterOptions['mocha-junit-reporter'] && Container.mocha().options.reporterOptions['mocha-junit-reporter'].attachments) {
+          test.attachments = [path.join(global.output_dir, fileName)];
+        }
+
         const allureReporter = Container.plugins('allure');
         if (allureReporter) {
           allureReporter.addAttachment('Last Seen Screenshot', fs.readFileSync(path.join(global.output_dir, fileName)), 'image/png');
diff --git a/lib/plugin/wdio.js b/lib/plugin/wdio.js
new file mode 100644
index 00000000..a0e817af
--- /dev/null
+++ b/lib/plugin/wdio.js
@@ -0,0 +1,231 @@
+const container = require('../container');
+const mainConfig = require('../config');
+const recorder = require('../recorder');
+const event = require('../event');
+const output = require('../output');
+const debug = require('debug')('codeceptjs:plugin:wdio');
+
+const defaultConfig = {
+  services: [],
+  capabilities: {},
+};
+
+let restartsSession;
+
+/**
+ * Webdriverio services runner.
+ *
+ * This plugin allows to run webdriverio services like:
+ *
+ * * selenium-standalone
+ * * sauce
+ * * testingbot
+ * * browserstack
+ * * appium
+ *
+ * A complete list of all available services can be found on [webdriverio website](https://webdriver.io).
+ *
+ * ###### Setup
+ *
+ * 1. Install a webdriverio service
+ * 2. Enable `wdio` plugin in config
+ * 3. Add service name to `services` array inside wdio plugin config.
+ *
+ * See examples below:
+ *
+ * ###### Selenium Standalone Service
+ *
+ * Install ` @wdio/selenium-standalone-service` package, as [described here](https://webdriver.io/docs/selenium-standalone-service.html).
+ * It is important to make sure it is compatible with current webdriverio version.
+ *
+ * Enable `wdio` plugin in plugins list and add `selenium-standalone` service:
+ *
+ * ```js
+ * plugins: {
+ *    wdio: {
+ *        services: ['selenium-standalone']
+ *        // additional config for service can be passed here
+ *    }
+ * }
+ * ```
+ *
+ * Please note, this service can be used with Protractor helper as well!
+ *
+ * ##### Sauce Service
+ *
+ * Install ` @wdio/sauce-service` package, as [described here](https://webdriver.io/docs/sauce-service.html).
+ * It is important to make sure it is compatible with current webdriverio version.
+ *
+ * Enable `wdio` plugin in plugins list and add `sauce` service:
+ *
+ * ```js
+ * plugins: {
+ *    wdio: {
+ *        services: ['sauce'],
+ *        user: ... ,// saucelabs username
+ *        key: ... // saucelabs api key
+ *        // additional config, from sauce service
+ *    }
+ * }
+ * ```
+ *
+ * ---
+ *
+ * In the same manner additional services from webdriverio can be installed, enabled, and configured.
+ *
+ * #### Configuration
+ *
+ * * `services` - list of enabled services
+ * * ... - additional configuration passed into services.
+ *
+ */
+module.exports = (config) => {
+  const webDriver = container.helpers('WebDriver');
+  if (webDriver) {
+    config = Object.assign(webDriver.options, config);
+    restartsSession = !!config.restart;
+  }
+  config = Object.assign(defaultConfig, config);
+
+  const services = [];
+  const launchers = [];
+
+  for (const name of config.services) {
+    // webdriverio v5 style
+    const Service = safeRequire(`@wdio/${name.toLowerCase()}-service`);
+    if (Service) {
+      if (Service.launcher && typeof Service.launcher === 'function') {
+        const Launcher = Service.launcher;
+        launchers.push(new Launcher(config));
+      }
+      if (typeof Service === 'function') {
+        services.push(new Service(config, config.capabilities));
+      }
+      continue;
+    }
+
+    throw new Error(`Couldn't initialize service ${name} from wdio plugin config.\nIt should be available either in '@wdio/${name.toLowerCase()}-service' package`);
+  }
+
+  debug(`services ${services}, launchers ${launchers}`);
+
+  recorder.startUnlessRunning();
+
+  for (const launcher of launchers) {
+    registerLauncher(launcher);
+  }
+
+  for (const service of services) {
+    registerService(service);
+  }
+
+  function registerService(service) {
+    const name = service.constructor.name;
+    if (service.beforeSession) {
+      event.dispatcher.on(event.all.before, () => {
+        recorder.add(`service ${name} all.before`, async () => {
+          await service.beforeSession(config, config.capabilities);
+        });
+      });
+    }
+
+    if (service.afterSession) {
+      event.dispatcher.on(event.all.result, (result) => {
+        recorder.add(`service ${name} all.after`, async () => {
+          await service.afterSession(result);
+        });
+      });
+    }
+
+    if (service.beforeSuite) {
+      event.dispatcher.on(event.suite.before, (suite) => {
+        debug(`suite started: ${suite.title}`);
+        recorder.add(`service ${name} suite.before`, () => service.beforeSuite(suite));
+      });
+    }
+
+    if (service.afterSuite) {
+      event.dispatcher.on(event.suite.after, (suite) => {
+        debug(`suite finished: ${suite.title}`);
+        recorder.add(`service ${name} suite.after`, () => service.afterSuite(suite));
+      });
+    }
+
+    if (service.beforeTest) {
+      event.dispatcher.on(event.test.started, async (test) => {
+        if (test.parent) {
+          test.parent.toString = () => test.parent.title;
+        }
+        // test.parent = test.parent ? test.parent.title : null;
+        debug(`test started: ${test.title}`);
+        if (webDriver) {
+          global.browser = webDriver.browser;
+          global.browser.config = Object.assign(mainConfig.get(), global.browser.config);
+        }
+        await service.beforeTest(test);
+      });
+    }
+
+    if (service.afterTest) {
+      event.dispatcher.on(event.test.finished, async (test) => {
+        debug(`test finished: ${test.title}`);
+        await service.afterTest(test);
+      });
+    }
+
+    if (restartsSession && service.before) {
+      event.dispatcher.on(event.test.started, test => service.before());
+    }
+
+    if (restartsSession && service.after) {
+      event.dispatcher.on(event.test.finished, test => service.after());
+    }
+
+    if (!restartsSession && service.before) {
+      let initializedBrowser = false;
+      event.dispatcher.on(event.test.started, async (test) => {
+        if (!initializedBrowser) {
+          await service.before();
+          initializedBrowser = true;
+        }
+      });
+    }
+
+    if (!restartsSession && service.after) {
+      event.dispatcher.on(event.all.result, result => service.after(result));
+    }
+  }
+
+  function registerLauncher(launcher) {
+    const name = launcher.constructor.name;
+    if (launcher.onPrepare) {
+      event.dispatcher.on(event.all.before, () => {
+        recorder.add(`launcher ${name} start`, async () => {
+          await launcher.onPrepare(config);
+          output.debug(`Started ${name}`);
+        });
+      });
+    }
+
+    if (launcher.onComplete) {
+      event.dispatcher.on(event.all.after, () => {
+        recorder.add(`launcher ${name} start`, async () => {
+          await launcher.onComplete(process.exitCode, config);
+          output.debug(`Stopped ${name}`);
+        });
+      });
+    }
+  }
+};
+
+
+function safeRequire(name) {
+  try {
+    return require(name);
+  } catch (e) {
+    if (!e.message.match(`Cannot find module '${name}'`)) {
+      throw new Error(`Couldn't initialise "${name}".\n${e.stack}`);
+    }
+    return null;
+  }
+}
diff --git a/lib/secret.js b/lib/secret.js
new file mode 100644
index 00000000..3f7f9041
--- /dev/null
+++ b/lib/secret.js
@@ -0,0 +1,15 @@
+class Secret {
+  constructor(secret) {
+    this._secret = secret;
+  }
+
+  toString() {
+    return this._secret;
+  }
+
+  static secret(secret) {
+    return new Secret(secret);
+  }
+}
+
+module.exports = Secret;
diff --git a/lib/step.js b/lib/step.js
index f0cf90da..1e770157 100644
--- a/lib/step.js
+++ b/lib/step.js
@@ -1,4 +1,5 @@
 const store = require('./store');
+const Secret = require('./secret');
 
 const STACK_LINE = 4;
 
@@ -76,6 +77,8 @@ class Step {
         return arg.toString();
       } else if (typeof arg === 'undefined') {
         return `${arg}`;
+      } else if (arg instanceof Secret) {
+        return '*****';
       } else if (arg.toString && arg.toString() !== '[object Object]') {
         return arg.toString();
       } else if (typeof arg === 'object') {


Test patch:
diff --git a/test/data/sandbox/codecept.multiple.features.js b/test/data/sandbox/codecept.multiple.features.js
new file mode 100644
index 00000000..c4ffcc9f
--- /dev/null
+++ b/test/data/sandbox/codecept.multiple.features.js
@@ -0,0 +1,26 @@
+exports.config = {
+  tests: './*_test.js',
+  timeout: 10000,
+  output: './output',
+  helpers: {
+    FileSystem: {},
+    BDD: {
+      require: './support/bdd_helper.js',
+    },
+  },
+  gherkin: {
+    features: './features/*.feature',
+    steps: [
+      './features/step_definitions/my_steps.js',
+      './features/step_definitions/my_other_steps.js',
+    ],
+  },
+  include: {},
+  bootstrap: false,
+  mocha: {},
+  multiple: {
+    chunks: {
+      chunks: 2,
+    },
+  },
+};
diff --git a/test/data/sandbox/features/secret.feature b/test/data/sandbox/features/secret.feature
new file mode 100644
index 00000000..96e6fa8e
--- /dev/null
+++ b/test/data/sandbox/features/secret.feature
@@ -0,0 +1,4 @@
+Feature: Secrets
+
+  Scenario: hide secret in step output
+    Given I login
diff --git a/test/data/sandbox/features/step_definitions/my_steps.js b/test/data/sandbox/features/step_definitions/my_steps.js
index 209e15ff..6ebd27e4 100644
--- a/test/data/sandbox/features/step_definitions/my_steps.js
+++ b/test/data/sandbox/features/step_definitions/my_steps.js
@@ -31,3 +31,6 @@ Then('I should see overall price is "{float}" $', (price) => {
   I.seeSum(price);
 });
 
+Given('I login', () => {
+  I.login('user', secret('password'));
+});
diff --git a/test/data/sandbox/support/bdd_helper.js b/test/data/sandbox/support/bdd_helper.js
index 730b5481..50d49260 100644
--- a/test/data/sandbox/support/bdd_helper.js
+++ b/test/data/sandbox/support/bdd_helper.js
@@ -37,7 +37,8 @@ class CheckoutHelper extends Helper {
       this.discountCalc();
     }
   }
+
+  login() {}
 }
 
 module.exports = CheckoutHelper;
-
diff --git a/test/helper/Appium_test.js b/test/helper/Appium_test.js
index 8fb06588..f219e0e3 100644
--- a/test/helper/Appium_test.js
+++ b/test/helper/Appium_test.js
@@ -2,6 +2,7 @@ const Appium = require('../../lib/helper/Appium');
 
 let app;
 const assert = require('assert');
+const expect = require('chai').expect;
 const path = require('path');
 const fs = require('fs');
 const fileExists = require('../../lib/utils').fileExists;
@@ -342,6 +343,64 @@ describe('Appium', function () {
         100, 700,
       );
     });
+
+    describe('#performTouchAction', () => {
+      it('should react on swipeUp action', function* () {
+        yield app.click("//android.widget.Button[@resource-id = 'io.selendroid.testapp:id/touchTest']");
+        yield app.waitForText(
+          'Gesture Type', 10,
+          "//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/gesture_type_text_view']",
+        );
+        yield app.performTouchAction('swipeUp');
+        const type = yield app.grabTextFrom("//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/gesture_type_text_view']");
+        const vy = yield app.grabTextFrom("//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/text_view4']");
+        console.log(vy.split(' ')[1]);
+        assert.equal(type, 'FLICK');
+        expect(vy.split(' ')[1]).to.be.below(1006);
+      });
+
+      it('should react on swipeDown action', function* () {
+        yield app.click("//android.widget.Button[@resource-id = 'io.selendroid.testapp:id/touchTest']");
+        yield app.waitForText(
+          'Gesture Type', 10,
+          "//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/gesture_type_text_view']",
+        );
+        yield app.performTouchAction('swipeDown');
+        const type = yield app.grabTextFrom("//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/gesture_type_text_view']");
+        const vy = yield app.grabTextFrom("//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/text_view4']");
+        console.log(vy.split(' ')[1]);
+        assert.equal(type, 'FLICK');
+        expect(vy.split(' ')[1]).to.be.above(178);
+      });
+
+      it('should react on swipeLeft action', function* () {
+        yield app.click("//android.widget.Button[@resource-id = 'io.selendroid.testapp:id/touchTest']");
+        yield app.waitForText(
+          'Gesture Type', 10,
+          "//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/gesture_type_text_view']",
+        );
+        yield app.performTouchAction('swipeLeft');
+        const type = yield app.grabTextFrom("//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/gesture_type_text_view']");
+        const vy = yield app.grabTextFrom("//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/text_view4']");
+        console.log(vy.split(' ')[1]);
+        assert.equal(type, 'FLICK');
+        expect(vy.split(' ')[1]).to.be.below(730);
+      });
+
+      it('should react on swipeRight action', function* () {
+        yield app.click("//android.widget.Button[@resource-id = 'io.selendroid.testapp:id/touchTest']");
+        yield app.waitForText(
+          'Gesture Type', 10,
+          "//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/gesture_type_text_view']",
+        );
+        yield app.performTouchAction('swipeRight');
+        const type = yield app.grabTextFrom("//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/gesture_type_text_view']");
+        const vy = yield app.grabTextFrom("//android.widget.TextView[@resource-id = 'io.selendroid.testapp:id/text_view4']");
+        console.log(vy.split(' ')[1]);
+        assert.equal(type, 'FLICK');
+        expect(vy.split(' ')[1]).to.be.below(38);
+      });
+    });
   });
 
   describe('#pullFile', () => {
diff --git a/test/helper/webapi.js b/test/helper/webapi.js
index fccacdf0..f9fa40c1 100644
--- a/test/helper/webapi.js
+++ b/test/helper/webapi.js
@@ -9,6 +9,7 @@ const path = require('path');
 const dataFile = path.join(__dirname, '/../data/app/db');
 const formContents = require('../../lib/utils').test.submittedData(dataFile);
 const fileExists = require('../../lib/utils').fileExists;
+const secret = require('../../lib/secret').secret;
 
 module.exports.init = function (testData) {
   data = testData;
@@ -369,6 +370,13 @@ module.exports.tests = function () {
       return assert.equal(formContents('name'), 'Nothing special');
     });
 
+    it('should fill input fields with secrets', function* () {
+      yield I.amOnPage('/form/field');
+      yield I.fillField('Name', secret('Something special'));
+      yield I.click('Submit');
+      return assert.equal(formContents('name'), 'Something special');
+    });
+
     it('should fill field by css', function* () {
       yield I.amOnPage('/form/field');
       yield I.fillField('#name', 'Nothing special');
diff --git a/test/runner/bdd_test.js b/test/runner/bdd_test.js
index d6bb9c7f..e7ba7e16 100644
--- a/test/runner/bdd_test.js
+++ b/test/runner/bdd_test.js
@@ -45,6 +45,15 @@ describe('BDD Gherkin', () => {
     });
   });
 
+  it('should obfuscate secret substeps in debug mode', (done) => {
+    exec(config_run_config('codecept.bdd.json') + ' --debug --grep "Secrets"', (err, stdout, stderr) => { //eslint-disable-line
+      stdout.should.include('Given I login'); // feature
+      stdout.should.not.include('password');
+      assert(!err);
+      done();
+    });
+  });
+
   it('should run feature with examples files', (done) => {
     exec(config_run_config('codecept.bdd.json') + ' --steps --grep "Checkout examples"', (err, stdout, stderr) => { //eslint-disable-line
       stdout.should.include(' order discount {"price":"10","total":"10.0"}');
diff --git a/test/runner/run_multiple_test.js b/test/runner/run_multiple_test.js
index 7d17f251..a4ee2dc6 100644
--- a/test/runner/run_multiple_test.js
+++ b/test/runner/run_multiple_test.js
@@ -135,6 +135,43 @@ describe('CodeceptJS Multiple Runner', function () {
     });
   });
 
+  it('should run features in parallel', (done) => {
+    process.chdir(codecept_dir);
+    exec(`${runner} run-multiple --config codecept.multiple.features.js chunks --features`, (err, stdout, stderr) => {
+      stdout.should.include('[1.chunks:chunk1:default] Checkout examples process');
+      stdout.should.not.include('[2.chunks:chunk2:default] Checkout examples process');
+      stdout.should.include('[2.chunks:chunk2:default] Checkout string');
+      stdout.should.not.include('[1.chunks:chunk1:default] Checkout string');
+      stdout.should.include('[1.chunks:chunk1:default]   OK  |');
+      stdout.should.include('[2.chunks:chunk2:default]   OK  |');
+      stdout.should.not.include('@feature_grep');
+      assert(!err);
+      done();
+    });
+  });
+
+  it('should run features & tests in parallel', (done) => {
+    process.chdir(codecept_dir);
+    exec(`${runner} run-multiple --config codecept.multiple.features.js chunks`, (err, stdout, stderr) => {
+      stdout.should.include('@feature_grep');
+      stdout.should.include('Checkout examples process');
+      stdout.should.include('Checkout string');
+      assert(!err);
+      done();
+    });
+  });
+
+  it('should run only tests in parallel', (done) => {
+    process.chdir(codecept_dir);
+    exec(`${runner} run-multiple --config codecept.multiple.features.js chunks --tests`, (err, stdout, stderr) => {
+      stdout.should.include('@feature_grep');
+      stdout.should.not.include('Checkout examples process');
+      stdout.should.not.include('Checkout string');
+      assert(!err);
+      done();
+    });
+  });
+
   describe('bootstrapAll and teardownAll', () => {
     const _codecept_run = `run-multiple --config ${codecept_dir}`;
 
diff --git a/test/unit/steps_test.js b/test/unit/steps_test.js
index abadbe86..44955e42 100644
--- a/test/unit/steps_test.js
+++ b/test/unit/steps_test.js
@@ -1,6 +1,7 @@
 const assert = require('assert');
 const Step = require('../../lib/step');
 const event = require('../../lib/event');
+const secret = require('../../lib/secret').secret;
 const sinon = require('sinon');
 
 let step;
@@ -33,6 +34,9 @@ describe('Step', () => {
     let testUndefined;
     step.args = [testUndefined, 'undefined'];
     step.humanizeArgs().should.eql('undefined, "undefined"');
+
+    step.args = [secret('word'), 1];
+    step.humanizeArgs().should.eql('*****, 1');
   });
 
   it('should provide nice output', () => {