Repository: Codeception/CodeceptJS (js)

Issue:
[regress] I.executeScript() Evaluation failed
#### What am I trying to achieve?
Execute script in `test.js` via built in `executeScript`
```javascript
// css like selector
let myArg = '[data-test-selector="news"] [data-test-id="701340"]';
let element = await I.executeScript(function(selectorLocator){
       return document.querySelector(selectorLocator);
    }, myArg);
```
#### What do I get instead?
```bash
Error: Evaluation failed: DOMException: Failed to execute 'querySelector' on 'Document': : '[object HTMLBodyElement]' is not a valid selector.
    at __puppeteer_evaluation_script__:3:35
```
### Details
Found this Regress after upgrade to `puppeteer 1.20.0`, because of `1.19.0` successfully executed same test scenario.
* CodeceptJS version: 2.3.1
* NodeJS Version: v12.2.0
* Operating System: Ubuntu 18.04.3 LTS
* Puppeteer 1.20.0

#### Workaround
1. Use `executeScript` without parameter:
```javascript
let selector = await I.executeScript(function(){
        return document.querySelector('[data-test-selector="news"] [data-test-id="701340"]');
    });
```
2. Downgrade to `"puppeteer": "~1.19.0"`

Pull request description:
Updated Puppeteer, fixed compatibility with Puppeteer 1.20
The issue was that executeScript was switching to a context of an iframe. Before 1.20 Puppeteer couldn't execute scripts on elements, now it can, so it was switching to element contexts too and failed on that.

Fixes #1894 

Patch:
diff --git a/bin/codecept.js b/bin/codecept.js
index 2616e6f1..4bb63c37 100755
--- a/bin/codecept.js
+++ b/bin/codecept.js
@@ -125,6 +125,7 @@ program.command('run-workers <workers>')
   .option('-c, --config [file]', 'configuration file to be used')
   .option('-g, --grep <pattern>', 'only run tests matching <pattern>')
   .option('-o, --override [value]', 'override current config options')
+  .option('--suites', 'parallel execution of suites not single tests')
   .option('--debug', 'output additional information')
   .option('--verbose', 'output internal logging information')
   .option('--features', 'run only *.feature files and skip tests')
diff --git a/docs/parallel.md b/docs/parallel.md
index 7340bd0b..71acb499 100644
--- a/docs/parallel.md
+++ b/docs/parallel.md
@@ -24,6 +24,12 @@ This command is similar to `run`, however, steps output can't be shown in worker
 
 Each worker spins an instance of CodeceptJS, executes a group of tests, and sends back report to the main process.
 
+By default the tests are assigned one by one to the avaible workers this may lead to multiple execution of `BeforeSuite()`. Use the option `--suites` to assigne the suites one by one to the workers.
+
+```sh
+npx codeceptjs run-workers --suites 2
+```
+
 
 ## Multiple Browsers Execution
 
diff --git a/lib/actor.js b/lib/actor.js
index 3ba3895d..f7da29e5 100644
--- a/lib/actor.js
+++ b/lib/actor.js
@@ -49,8 +49,10 @@ module.exports = function (obj) {
 function retryStep(opts) {
   if (opts === undefined) opts = 1;
   recorder.retry(opts);
+  // adding an empty promise to clear retries
+  recorder.add(_ => null);
   // remove retry once the step passed
-  recorder.add(_ => event.dispatcher.once(event.step.passed, _ => recorder.retries.pop()));
+  recorder.add(_ => event.dispatcher.once(event.step.finished, _ => recorder.retries.pop()));
   return this;
 }
 
diff --git a/lib/command/run-workers.js b/lib/command/run-workers.js
index cfd9b83d..2309c803 100644
--- a/lib/command/run-workers.js
+++ b/lib/command/run-workers.js
@@ -50,7 +50,7 @@ module.exports = function (workers, options) {
   codecept.init(testRoot);
   codecept.loadTests();
 
-  const groups = createGroupsOfTests(codecept, numberOfWorkers);
+  const groups = createGroupsOfTests(codecept, numberOfWorkers, options.suites);
 
   stats.start = new Date();
 
@@ -60,17 +60,15 @@ module.exports = function (workers, options) {
 
   // run bootstrap all
   runHook(config.bootstrapAll, () => {
-    const workerList = createWorkers(groups, config, options, testRoot);
+    const workerList = createWorkers(groups, options, testRoot);
     workerList.forEach(worker => assignWorkerMethods(worker, groups.length));
   }, 'bootstrapAll');
 
-  function createWorkers(groups, config, options, testRoot) {
-    delete config.mocha;
+  function createWorkers(groups, options, testRoot) {
     const workers = groups.map((tests, workerIndex) => {
       workerIndex++;
       return new Worker(pathToWorker, {
         workerData: {
-          config: simplifyObject(config, { objects: false, underscores: false }),
           options: simplifyObject(options),
           tests,
           testRoot,
@@ -121,7 +119,7 @@ function printResults() {
   stats.duration = stats.end - stats.start;
   output.print();
   if (stats.passes && !errors.length) {
-    output.result(stats.passes, stats.failures, 0, stats.duration);
+    output.result(stats.passes, stats.failures, 0, `${stats.duration / 1000}s`);
   }
   if (stats.failures) {
     output.print();
@@ -130,28 +128,59 @@ function printResults() {
   }
 }
 
-function createGroupsOfTests(codecept, numberOfGroups) {
+function createGroupsOfTests(codecept, numberOfGroups, preserveSuites) {
   const files = codecept.testFiles;
   const mocha = Container.mocha();
   mocha.files = files;
   mocha.loadFiles();
 
   const groups = [];
+  for (let i = 0; i < numberOfGroups; i++) {
+    groups[i] = [];
+  }
+  if (preserveSuites) {
+    return groupSuites(mocha.suite, groups);
+  }
+  return groupTests(mocha.suite, groups);
+}
+
+function groupTests(suite, groups) {
   let groupCounter = 0;
 
-  mocha.suite.eachTest((test) => {
-    const i = groupCounter % numberOfGroups;
-    if (groups[i] === undefined) groups[i] = [];
+  suite.eachTest((test) => {
+    const i = groupCounter % groups.length;
     if (test) {
       const { id } = test;
       groups[i].push(id);
       groupCounter++;
     }
   });
+  return groups;
+}
 
+function groupSuites(suite, groups) {
+  suite.suites.forEach((suite) => {
+    const i = indexOfSmallestElement(groups);
+    suite.tests.forEach((test) => {
+      if (test) {
+        const { id } = test;
+        groups[i].push(id);
+      }
+    });
+  });
   return groups;
 }
 
+function indexOfSmallestElement(groups) {
+  let i = 0;
+  for (let j = 1; j < groups.length; j++) {
+    if (groups[j - 1].length > groups[j].length) {
+      i = j;
+    }
+  }
+  return i;
+}
+
 function appendStats(newStats) {
   stats.passes += newStats.passes;
   stats.failures += newStats.failures;
@@ -159,7 +188,7 @@ function appendStats(newStats) {
 }
 
 function repackTest(test) {
-  test = Object.assign(new Test(test.title || '', () => {}), test);
+  test = Object.assign(new Test(test.title || '', () => { }), test);
   test.parent = Object.assign(new Suite(test.parent.title), test.parent);
   return test;
 }
@@ -169,22 +198,13 @@ function repackSuite(suite) {
 }
 
 
-function simplifyObject(object, remove = {}) {
-  const defaultRemove = {
-    objects: true,
-    functions: true,
-    underscores: true,
-  };
-
-  remove = Object.assign(defaultRemove, remove);
-
-  let tempObj = Object.keys(object);
-  if (remove.underscores) tempObj = tempObj.filter(k => k.indexOf('_') !== 0);
-  if (remove.functions) tempObj = tempObj.filter(k => typeof object[k] !== 'function');
-  if (remove.objects) tempObj = tempObj.filter(k => typeof object[k] !== 'object');
-
-  return tempObj.reduce((obj, key) => {
-    obj[key] = object[key];
-    return obj;
-  }, {});
+function simplifyObject(object) {
+  return Object.keys(object)
+    .filter(k => k.indexOf('_') !== 0)
+    .filter(k => typeof object[k] !== 'function')
+    .filter(k => typeof object[k] !== 'object')
+    .reduce((obj, key) => {
+      obj[key] = object[key];
+      return obj;
+    }, {});
 }
diff --git a/lib/container.js b/lib/container.js
index dae00e4d..70eea169 100644
--- a/lib/container.js
+++ b/lib/container.js
@@ -292,12 +292,41 @@ function loadSupportObject(modulePath, supportObjectName) {
     modulePath = path.join(global.codecept_dir, modulePath);
   }
   try {
-    return require(modulePath);
+    const obj = require(modulePath);
+    if (typeof obj !== 'function'
+        && Object.getPrototypeOf(obj) !== Object.prototype
+        && !Array.isArray(obj)
+    ) {
+      return getObjectMethods(obj);
+    }
+
+    return obj;
   } catch (err) {
     throw new Error(`Could not include object ${supportObjectName} from module '${modulePath}'\n${err.message}`);
   }
 }
 
+/**
+ * Method collect own property and prototype
+ */
+function getObjectMethods(obj) {
+  const methodsSet = new Set();
+  let protoObj = Reflect.getPrototypeOf(obj);
+  do {
+    if (protoObj.constructor.prototype !== Object.prototype) {
+      const keys = Reflect.ownKeys(protoObj);
+      keys.forEach(k => methodsSet.add(k));
+    }
+  }
+  while (protoObj = Reflect.getPrototypeOf(protoObj));
+  Reflect.ownKeys(obj).forEach(k => methodsSet.add(k));
+  const methods = {};
+  for (const key of methodsSet.keys()) {
+    if (key !== 'constructor') methods[key] = obj[key];
+  }
+  return methods;
+}
+
 function loadTranslation(translation) {
   if (!translation) {
     return new Translation({
diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js
index a54280ce..1f26890f 100644
--- a/lib/helper/Puppeteer.js
+++ b/lib/helper/Puppeteer.js
@@ -218,7 +218,7 @@ class Puppeteer extends Helper {
 
   async _before() {
     recorder.retry({
-      retries: 3,
+      retries: 5,
       when: err => err.message.indexOf('context') > -1, // ignore context errors
     });
     if (this.options.restart && !this.options.manualStart) return this._startBrowser();
@@ -1356,8 +1356,8 @@ class Puppeteer extends Helper {
    */
   async executeScript(fn) {
     let context = this.page;
-    if (this.context && typeof this.context.evaluate === 'function') {
-      context = this.context;
+    if (this.context && this.context.constructor.name === 'Frame') {
+      context = this.context; // switching to iframe context
     }
     return context.evaluate.apply(context, arguments);
   }
@@ -1677,7 +1677,7 @@ class Puppeteer extends Helper {
     if (locator.isCSS()) {
       waiter = context.waitForSelector(locator.simplify(), { timeout: waitTimeout });
     } else {
-      waiter = context.waitForFunction($XPath, { timeout: waitTimeout }, null, locator.value);
+      waiter = context.waitForXPath(locator.value, { timeout: waitTimeout });
     }
     return waiter.catch((err) => {
       throw new Error(`element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${err.message}`);
@@ -1698,11 +1698,7 @@ class Puppeteer extends Helper {
     if (locator.isCSS()) {
       waiter = context.waitForSelector(locator.simplify(), { timeout: waitTimeout, visible: true });
     } else {
-      const visibleFn = function (locator, $XPath) {
-        eval($XPath); // eslint-disable-line no-eval
-        return $XPath(null, locator).filter(el => el.offsetParent !== null).length > 0;
-      };
-      waiter = context.waitForFunction(visibleFn, { timeout: waitTimeout }, locator.value, $XPath.toString());
+      waiter = context.waitForXPath(locator.value, { timeout: waitTimeout, visible: true });
     }
     return waiter.catch((err) => {
       throw new Error(`element (${locator.toString()}) still not visible after ${waitTimeout / 1000} sec\n${err.message}`);
@@ -1721,12 +1717,7 @@ class Puppeteer extends Helper {
     if (locator.isCSS()) {
       waiter = context.waitForSelector(locator.simplify(), { timeout: waitTimeout, hidden: true });
     } else {
-      const visibleFn = function (locator, $XPath) {
-        eval($XPath); // eslint-disable-line no-eval
-        const matches = $XPath(null, locator);
-        return matches.length === 0 || matches.filter(el => el.offsetParent === null).length > 0;
-      };
-      waiter = context.waitForFunction(visibleFn, { timeout: waitTimeout }, locator.value, $XPath.toString());
+      waiter = context.waitForXPath(locator.value, { timeout: waitTimeout, hidden: true });
     }
     return waiter.catch((err) => {
       throw new Error(`element (${locator.toString()}) still visible after ${waitTimeout / 1000} sec\n${err.message}`);
@@ -1744,11 +1735,7 @@ class Puppeteer extends Helper {
     if (locator.isCSS()) {
       waiter = context.waitForSelector(locator.simplify(), { timeout: waitTimeout, hidden: true });
     } else {
-      const visibleFn = function (locator, $XPath) {
-        eval($XPath); // eslint-disable-line no-eval
-        return $XPath(null, locator).filter(el => el.offsetParent !== null).length === 0;
-      };
-      waiter = context.waitForFunction(visibleFn, { timeout: waitTimeout }, locator.value, $XPath.toString());
+      waiter = context.waitForXPath(locator.value, { timeout: waitTimeout, hidden: true });
     }
     return waiter.catch((err) => {
       throw new Error(`element (${locator.toString()}) still not hidden after ${waitTimeout / 1000} sec\n${err.message}`);
diff --git a/lib/plugin/retryFailedStep.js b/lib/plugin/retryFailedStep.js
index 7c3dd207..40c56fa9 100644
--- a/lib/plugin/retryFailedStep.js
+++ b/lib/plugin/retryFailedStep.js
@@ -3,6 +3,15 @@ const recorder = require('../recorder');
 
 const defaultConfig = {
   retries: 5,
+  defaultIgnoredSteps: [
+    'amOnPage',
+    'wait*',
+    'send*',
+    'execute*',
+    'run*',
+    'have*',
+  ],
+  ignoredSteps: [],
 };
 
 /**
@@ -22,7 +31,7 @@ const defaultConfig = {
  * Run tests with plugin enabled:
  *
  * ```
- * codeceptjs run --plugins retryFailedStep
+ * npx codeceptjs run --plugins retryFailedStep
  * ```
  *
  * ##### Configuration:
@@ -33,15 +42,53 @@ const defaultConfig = {
  * * `minTimeout` - The number of milliseconds before starting the first retry. Default is 1000.
  * * `maxTimeout` - The maximum number of milliseconds between two retries. Default is Infinity.
  * * `randomize` - Randomizes the timeouts by multiplying with a factor between 1 to 2. Default is false.
+ * * `defaultIgnoredSteps` - an array of steps to be ignored for retry. Includes:
+ *     * `amOnPage`
+ *     * `wait*`
+ *     * `send*`
+ *     * `execute*`
+ *     * `run*`
+ *     * `have*`
+ * * `ignoredSteps` - an array for custom steps to ignore on retry. Use it to append custom steps to ignored list.
+ * You can use step names or step prefixes ending with `*`. As such, `wait*` will match all steps starting with `wait`.
+ * To append your own steps to ignore list - copy and paste a default steps list. Regexp values are accepted as well.
  *
- * This plugin is very basic so it's recommended to improve it to match your custom needs.
+ * ##### Example
+ *
+ * ```js
+ * plugins: {
+ *     retryFailedStep: {
+ *         enabled: true,
+ *         ignoreSteps: [
+ *           'scroll*', // ignore all scroll steps
+ *           /Cookie/, // ignore all steps with a Cookie in it (by regexp)
+ *         ]
+ *     }
+ * }
+ * ```
+ *
+ * ##### Disable Per Test
+ *
+ * This plugin can be disabled per test. In this case you will need to stet `I.retry()` to all flaky steps:
+ *
+ * Use scenario configuration to disable plugin for a test
+ *
+ * ```js
+ * Scenario('scenario tite', () => {
+ *    // test goes here
+ * }).config(test => test.disableRetryFailedStep = true)
+ * ```
  *
  */
 module.exports = (config) => {
   config = Object.assign(defaultConfig, config);
+  config.ignoredSteps = config.ignoredSteps.concat(config.defaultIgnoredSteps);
   const customWhen = config.when;
 
+  let enableRetry = false;
+
   const when = (err) => {
+    if (!enableRetry) return;
     const store = require('../store');
     if (store.debugMode) return false;
     if (customWhen) return customWhen(err);
@@ -49,7 +96,22 @@ module.exports = (config) => {
   };
   config.when = when;
 
+  event.dispatcher.on(event.step.started, (step) => {
+    // if a step is ignored - return
+    for (const ignored of config.ignoredSteps) {
+      if (step.name === ignored) return;
+      if (ignored instanceof RegExp && step.name.match(ignored)) return;
+      if (ignored.indexOf('*') && step.name.startsWith(ignored.slice(0, -1))) return;
+    }
+    enableRetry = true; // enable retry for a step
+  });
+
+  event.dispatcher.on(event.step.finished, (step) => {
+    enableRetry = false;
+  });
+
   event.dispatcher.on(event.test.before, (test) => {
+    if (test && test.disableRetryFailedStep) return; // disable retry when a test is not active
     recorder.retry(config);
   });
 };
diff --git a/lib/plugin/screenshotOnFail.js b/lib/plugin/screenshotOnFail.js
index b8792a9a..f8fa45a6 100644
--- a/lib/plugin/screenshotOnFail.js
+++ b/lib/plugin/screenshotOnFail.js
@@ -88,7 +88,7 @@ module.exports = function (config) {
         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 (options.uniqueScreenshotNames) {
+      if (options.uniqueScreenshotNames && test) {
         const uuid = test.uuid || test.ctx.test.uuid || Math.floor(new Date().getTime() / 1000);
         fileName = `${fileName.substring(0, 10)}_${uuid}.failed.png`;
       } else {
diff --git a/lib/utils.js b/lib/utils.js
index 921897ea..bf3a93d0 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -140,6 +140,7 @@ module.exports.chunkArray = function (arr, chunk) {
 };
 
 module.exports.clearString = function (str) {
+  if (!str) return '';
   /* Replace forbidden symbols in string
    */
   return str
diff --git a/package.json b/package.json
index 9dd3dd9b..80db622d 100644
--- a/package.json
+++ b/package.json
@@ -48,13 +48,13 @@
     "escape-string-regexp": "^1.0.3",
     "figures": "^2.0.0",
     "fn-args": "^4.0.0",
-    "fs-extra": "^8.0.1",
+    "fs-extra": "^8.1.0",
     "gherkin": "^5.1.0",
     "glob": "^6.0.1",
-    "inquirer": "^6.4.1",
-    "js-beautify": "^1.10.0",
+    "inquirer": "^6.5.2",
+    "js-beautify": "^1.10.2",
     "lodash.clonedeep": "^4.5.0",
-    "lodash.merge": "^4.6.1",
+    "lodash.merge": "^4.6.2",
     "mkdirp": "^0.5.1",
     "mocha": "^6.2.0",
     "mocha-junit-reporter": "^1.23.1",
@@ -62,44 +62,44 @@
     "parse-function": "^5.2.10",
     "promise-retry": "^1.1.1",
     "requireg": "^0.1.8",
-    "resq": "^1.5.0",
+    "resq": "^1.6.0",
     "semver": "^6.2.0",
     "sprintf-js": "^1.1.1"
   },
   "devDependencies": {
     "@codeceptjs/detox-helper": "^1.0.1",
-    "@pollyjs/adapter-puppeteer": "^2.5.0",
-    "@pollyjs/core": "^2.5.0",
+    "@pollyjs/adapter-puppeteer": "^2.6.2",
+    "@pollyjs/core": "^2.6.2",
     "@types/inquirer": "^0.0.35",
     "@types/mocha": "^5.2.7",
-    "@types/node": "^8.10.48",
-    "@wdio/sauce-service": "^5.8.0",
-    "@wdio/selenium-standalone-service": "^5.8.0",
-    "@wdio/utils": "^5.8.0",
-    "apollo-server-express": "^2.8.1",
+    "@types/node": "^8.10.54",
+    "@wdio/sauce-service": "^5.12.5",
+    "@wdio/selenium-standalone-service": "^5.12.1",
+    "@wdio/utils": "^5.13.0",
+    "apollo-server-express": "^2.9.3",
     "chai": "^3.4.1",
     "chai-as-promised": "^5.2.0",
     "documentation": "^8.1.2",
     "eslint": "^4.17.0",
     "eslint-config-airbnb-base": "^12.1.0",
-    "eslint-plugin-import": "^2.18.0",
+    "eslint-plugin-import": "^2.18.2",
     "eslint-plugin-mocha": "^5.3.0",
     "express": "^4.17.1",
     "faker": "^4.1.0",
-    "graphql": "^14.4.2",
+    "graphql": "^14.5.7",
     "husky": "^1.2.1",
     "json-server": "^0.10.1",
     "nightmare": "^3.0.2",
-    "nodemon": "^1.19.1",
+    "nodemon": "^1.19.2",
     "protractor": "^5.4.1",
-    "puppeteer": "^1.18.1",
+    "puppeteer": "^1.20.0",
     "rosie": "^1.6.0",
     "sinon": "^1.17.2",
     "sinon-chai": "^2.14.0",
-    "testcafe": "^1.2.1",
+    "testcafe": "^1.5.0",
     "typescript": "^2.9.2",
     "wdio-docker-service": "^1.5.0",
-    "webdriverio": "^5.10.9",
+    "webdriverio": "^5.13.1",
     "xmldom": "^0.1.27",
     "xpath": "0.0.27"
   },


Test patch:
diff --git a/test/data/sandbox/codecept.retryFailed.json b/test/data/sandbox/codecept.retryFailed.json
new file mode 100644
index 00000000..2b3e78ba
--- /dev/null
+++ b/test/data/sandbox/codecept.retryFailed.json
@@ -0,0 +1,19 @@
+{
+  "tests": "./*_test.retryFailed.js",
+  "timeout": 10000,
+  "output": "./output",
+  "helpers": {
+    "Retry": {
+      "require": "./retry_helper.js"
+    }
+  },
+  "plugins": {
+    "retryFailedStep": {
+      "enabled": true,
+      "retries": 5
+    }
+  },
+  "bootstrap": false,
+  "mocha": {},
+  "name": "sandbox"
+}
\ No newline at end of file
diff --git a/test/data/sandbox/configs/pageObjects/codecept.conf.js b/test/data/sandbox/configs/pageObjects/codecept.conf.js
new file mode 100644
index 00000000..02987f98
--- /dev/null
+++ b/test/data/sandbox/configs/pageObjects/codecept.conf.js
@@ -0,0 +1,16 @@
+exports.config = {
+  tests: './*_test.js',
+  output: './output',
+  helpers: {
+    CustomHelper: {
+      require: './customHelper.js',
+    },
+  },
+  include: {
+    classpage: './pages/classpage.js',
+    classnestedpage: './pages/classnestedpage.js',
+  },
+  bootstrap: null,
+  mocha: {},
+  name: 'pageobject-as-class',
+};
diff --git a/test/data/sandbox/configs/pageObjects/customHelper.js b/test/data/sandbox/configs/pageObjects/customHelper.js
new file mode 100644
index 00000000..c7645a9a
--- /dev/null
+++ b/test/data/sandbox/configs/pageObjects/customHelper.js
@@ -0,0 +1,10 @@
+// const Helper = require('../../lib/helper');
+
+class CustomHelper extends Helper {
+  printMessage(s) {
+    // this.debug('Print message from CustomHelper');
+    console.log(s);
+  }
+}
+
+module.exports = CustomHelper;
diff --git a/test/data/sandbox/configs/pageObjects/first_test.js b/test/data/sandbox/configs/pageObjects/first_test.js
new file mode 100644
index 00000000..ef4520de
--- /dev/null
+++ b/test/data/sandbox/configs/pageObjects/first_test.js
@@ -0,0 +1,12 @@
+Feature('PageObject');
+
+Scenario('@ClassPageObject', async (I, classpage) => {
+  await classpage.type('Class Page Type');
+  await classpage.purgeDomains();
+});
+
+
+Scenario('@NestedClassPageObject', (I, classnestedpage) => {
+  classnestedpage.type('Nested Class Page Type');
+  classnestedpage.purgeDomains();
+});
diff --git a/test/data/sandbox/configs/pageObjects/pages/classnestedpage.js b/test/data/sandbox/configs/pageObjects/pages/classnestedpage.js
new file mode 100644
index 00000000..d572c11e
--- /dev/null
+++ b/test/data/sandbox/configs/pageObjects/pages/classnestedpage.js
@@ -0,0 +1,20 @@
+const { I } = inject();
+
+class Base {
+  async type(s) {
+    await I.printMessage(s);
+  }
+}
+
+class PageObject extends Base {
+  constructor() {
+    super();
+    this.method1 = () => { console.log(123); };
+  }
+
+  purgeDomains() {
+    I.printMessage('purgeDomains');
+  }
+}
+
+module.exports = new PageObject();
diff --git a/test/data/sandbox/configs/pageObjects/pages/classpage.js b/test/data/sandbox/configs/pageObjects/pages/classpage.js
new file mode 100644
index 00000000..70fd4cf5
--- /dev/null
+++ b/test/data/sandbox/configs/pageObjects/pages/classpage.js
@@ -0,0 +1,17 @@
+const { I } = inject();
+
+class PageObject {
+  constructor() {
+    this.method1 = () => { console.log(123); };
+  }
+
+  async type(s) {
+    await I.printMessage(s);
+  }
+
+  purgeDomains() {
+    I.printMessage('purgeDomains');
+  }
+}
+
+module.exports = new PageObject();
diff --git a/test/data/sandbox/flaky_test.retry.js b/test/data/sandbox/flaky_test.retry.js
index c0fd5668..53835f60 100644
--- a/test/data/sandbox/flaky_test.retry.js
+++ b/test/data/sandbox/flaky_test.retry.js
@@ -1,15 +1,32 @@
 const assert = require('assert');
 
+const recorder = codeceptjs.recorder;
+
 let tries = 0;
 
 Feature('Retry');
 
-Scenario('flaky step', async (I) => {
+Scenario('flaky step @test1', async (I) => {
   tries++;
   await I.retry(3).failWhen(() => {
     tries++;
     return tries < 4;
   });
   assert.equal(tries, 4);
+});
+
+Scenario('flaky step passed globally @test2', (I) => {
+  recorder.retry({
+    retries: 3,
+    when: err => false,
+  });
+  I.retry(5).asyncStep();
+  I.failWhen(() => {
+    tries++;
+    return tries < 4;
+  });
+});
+
+After(() => {
   console.log(`[T] Retries: ${tries}`);
 });
diff --git a/test/data/sandbox/flaky_test.retryFailed.js b/test/data/sandbox/flaky_test.retryFailed.js
new file mode 100644
index 00000000..d605d2e9
--- /dev/null
+++ b/test/data/sandbox/flaky_test.retryFailed.js
@@ -0,0 +1,35 @@
+const assert = require('assert');
+
+let tries = 0;
+
+Feature('Retry');
+
+Scenario('auto repeat failing step @test1', async (I) => {
+  tries++;
+  await I.failWhen(() => {
+    tries++;
+    return tries < 5;
+  });
+  assert.equal(tries, 5);
+  console.log(`[T] Retries: ${tries}`);
+});
+
+Scenario('no repeat for waiter @test2', async (I) => {
+  await I.waitForFail(() => {
+    tries++;
+    return tries < 5;
+  });
+  assert.equal(tries, 1);
+});
+
+Scenario('no retries if disabled per test @test3', async (I) => {
+  await I.failWhen(() => {
+    tries++;
+    return tries < 5;
+  });
+  assert.equal(tries, 1);
+}).config(test => test.disableRetryFailedStep = true);
+
+After(() => {
+  console.log(`[T] Retries: ${tries}`);
+});
diff --git a/test/data/sandbox/retry_helper.js b/test/data/sandbox/retry_helper.js
index 1150550b..c1ca038c 100644
--- a/test/data/sandbox/retry_helper.js
+++ b/test/data/sandbox/retry_helper.js
@@ -4,6 +4,14 @@ class Retry extends Helper {
   failWhen(fn) {
     if (fn()) throw new Error('ups, error');
   }
+
+  waitForFail(fn) {
+    if (fn()) throw new Error('ups, error');
+  }
+
+  asyncStep() {
+    return new Promise(resolve => setTimeout(resolve, 500));
+  }
 }
 
 module.exports = Retry;
diff --git a/test/helper/webapi.js b/test/helper/webapi.js
index d7718816..ff0c2698 100644
--- a/test/helper/webapi.js
+++ b/test/helper/webapi.js
@@ -377,6 +377,15 @@ module.exports.tests = function () {
       assert.equal(val, 10);
     });
 
+
+    it('should return value from sync script in iframe', async () => {
+      if (isHelper('TestCafe') || isHelper('Nightmare')) return; // TODO Not yet implemented
+      await I.amOnPage('/iframe');
+      await I.switchTo('iframe');
+      const val = await I.executeScript(() => document.getElementsByTagName('h1')[0].innerText);
+      assert.equal(val, 'Information');
+    });
+
     it('should execute async script', async () => {
       if (isHelper('TestCafe')) return; // TODO Not yet implemented
 
diff --git a/test/runner/interface_test.js b/test/runner/interface_test.js
index 38dc706d..4e810fb4 100644
--- a/test/runner/interface_test.js
+++ b/test/runner/interface_test.js
@@ -27,7 +27,7 @@ describe('CodeceptJS Interface', () => {
   });
 
   it('should rerun retried steps', (done) => {
-    exec(config_run_config('codecept.retry.json'), (err, stdout, stderr) => {
+    exec(`${config_run_config('codecept.retry.json')} --grep @test1`, (err, stdout, stderr) => {
       stdout.should.include('Retry'); // feature
       stdout.should.include('Retries: 4'); // test name
       assert(!err);
@@ -35,6 +35,43 @@ describe('CodeceptJS Interface', () => {
     });
   });
 
+  it('should not propagate retries to non retried steps', (done) => {
+    exec(`${config_run_config('codecept.retry.json')} --grep @test2 --verbose`, (err, stdout, stderr) => {
+      stdout.should.include('Retry'); // feature
+      stdout.should.include('Retries: 1'); // test name
+      assert(err);
+      done();
+    });
+  });
+
+  it('should use retryFailedStep plugin for failed steps', (done) => {
+    exec(`${config_run_config('codecept.retryFailed.json')} --grep @test1`, (err, stdout, stderr) => {
+      stdout.should.include('Retry'); // feature
+      stdout.should.include('Retries: 5'); // test name
+      assert(!err);
+      done();
+    });
+  });
+
+  it('should not retry wait* steps in retryFailedStep plugin', (done) => {
+    exec(`${config_run_config('codecept.retryFailed.json')} --grep @test2`, (err, stdout, stderr) => {
+      stdout.should.include('Retry'); // feature
+      stdout.should.not.include('Retries: 5');
+      stdout.should.include('Retries: 1');
+      assert(err);
+      done();
+    });
+  });
+
+  it('should not retry steps if retryFailedStep plugin disabled', (done) => {
+    exec(`${config_run_config('codecept.retryFailed.json')} --grep @test3`, (err, stdout, stderr) => {
+      stdout.should.include('Retry'); // feature
+      stdout.should.not.include('Retries: 5');
+      stdout.should.include('Retries: 1');
+      assert(err);
+      done();
+    });
+  });
 
   it('should include grep option tests', (done) => {
     exec(config_run_config('codecept.grep.json'), (err, stdout, stderr) => {
diff --git a/test/runner/pageobject_test.js b/test/runner/pageobject_test.js
new file mode 100644
index 00000000..61860770
--- /dev/null
+++ b/test/runner/pageobject_test.js
@@ -0,0 +1,43 @@
+const assert = require('assert');
+const path = require('path');
+const exec = require('child_process').exec;
+
+const runner = path.join(__dirname, '/../../bin/codecept.js');
+const codecept_dir = path.join(__dirname, '/../data/sandbox/configs/pageObjects');
+const codecept_run = `${runner} run`;
+const config_run_config = (config, grep) => `${codecept_run} --config ${codecept_dir}/${config} ${grep ? `--grep "${grep}"` : ''}`;
+const config_run_override = config => `${codecept_run} --override '${JSON.stringify(config)}'`;
+
+describe('CodeceptJS Interface', () => {
+  before(() => {
+    process.chdir(codecept_dir);
+  });
+
+  it('should inject page objects by class', (done) => {
+    exec(`${config_run_config('codecept.conf.js', '@ClassPageObject')} --debug`, (err, stdout) => {
+      stdout.should.not.include('classpage.type is not a function');
+      stdout.should.include('classpage: type');
+      stdout.should.include('I print message "Class Page Type"');
+      stdout.should.include('classpage: purgeDomains');
+      stdout.should.include('I print message "purgeDomains"');
+      stdout.should.include('Class Page Type');
+      stdout.should.include('OK  | 1 passed');
+      assert(!err);
+      done();
+    });
+  });
+
+  it('should inject page objects by class which nested base clas', (done) => {
+    exec(`${config_run_config('codecept.conf.js', '@NestedClassPageObject')} --debug`, (err, stdout) => {
+      stdout.should.not.include('classnestedpage.type is not a function');
+      stdout.should.include('classnestedpage: type');
+      stdout.should.include('I print message "Nested Class Page Type"');
+      stdout.should.include('classnestedpage: purgeDomains');
+      stdout.should.include('I print message "purgeDomains"');
+      stdout.should.include('Nested Class Page Type');
+      stdout.should.include('OK  | 1 passed');
+      assert(!err);
+      done();
+    });
+  });
+});
diff --git a/test/runner/run_multiple_test.js b/test/runner/run_multiple_test.js
index a65b8dbd..01ce31ab 100644
--- a/test/runner/run_multiple_test.js
+++ b/test/runner/run_multiple_test.js
@@ -176,7 +176,6 @@ describe('CodeceptJS Multiple Runner', function () {
     const _codecept_run = `run-multiple --config ${codecept_dir}`;
     it('should be executed from async function in config', (done) => {
       exec(`${runner} ${_codecept_run}/codecept.async.bootstrapall.multiple.code.js default`, (err, stdout, stderr) => {
-        console.log(stdout);
         stdout.should.include('CodeceptJS'); // feature
         stdout.should.include('Results: inside Promise\n"event.multiple.before" is called');
         stdout.should.include('"teardownAll" is called.');
diff --git a/test/unit/plugin/retryFailedStep_test.js b/test/unit/plugin/retryFailedStep_test.js
index 9ef0fc6b..a5e0335f 100644
--- a/test/unit/plugin/retryFailedStep_test.js
+++ b/test/unit/plugin/retryFailedStep_test.js
@@ -16,9 +16,15 @@ describe('retryFailedStep', () => {
     });
     recorder.start();
   });
+
+  afterEach(() => {
+    event.dispatcher.emit(event.step.finished, { });
+  });
+
   it('should retry failed step', async () => {
     retryFailedStep({ retries: 2, minTimeout: 1 });
-    event.dispatcher.emit(event.test.before);
+    event.dispatcher.emit(event.test.before, {});
+    event.dispatcher.emit(event.step.started, { name: 'click' });
 
     let counter = 0;
     recorder.add(() => {
@@ -31,10 +37,10 @@ describe('retryFailedStep', () => {
   });
   it('should not retry within', async () => {
     retryFailedStep({ retries: 1, minTimeout: 1 });
-    event.dispatcher.emit(event.test.before);
+    event.dispatcher.emit(event.test.before, {});
 
     let counter = 0;
-
+    event.dispatcher.emit(event.step.started, { name: 'click' });
     try {
       within('foo', () => {
         recorder.add(() => {
@@ -50,10 +56,81 @@ describe('retryFailedStep', () => {
     // expects to retry only once
     counter.should.equal(2);
   });
+
+  it('should not retry steps with wait*', async () => {
+    retryFailedStep({ retries: 2, minTimeout: 1 });
+    event.dispatcher.emit(event.test.before, {});
+
+    let counter = 0;
+    event.dispatcher.emit(event.step.started, { name: 'waitForElement' });
+    try {
+      recorder.add(() => {
+        counter++;
+        if (counter < 3) {
+          throw new Error();
+        }
+      });
+      await recorder.promise();
+    } catch (e) {
+      recorder.catchWithoutStop((err) => {
+      });
+    }
+
+    counter.should.equal(1);
+    // expects to retry only once
+  });
+
+  it('should not retry steps with amOnPage', async () => {
+    retryFailedStep({ retries: 2, minTimeout: 1 });
+    event.dispatcher.emit(event.test.before, {});
+
+    let counter = 0;
+    event.dispatcher.emit(event.step.started, { name: 'amOnPage' });
+    try {
+      recorder.add(() => {
+        counter++;
+        if (counter < 3) {
+          throw new Error();
+        }
+      });
+      await recorder.promise();
+    } catch (e) {
+      recorder.catchWithoutStop((err) => {
+      });
+    }
+
+    counter.should.equal(1);
+    // expects to retry only once
+  });
+
+
+  it('should add custom steps to ignore', async () => {
+    retryFailedStep({ retries: 2, minTimeout: 1, ignoredSteps: ['somethingNew*'] });
+    event.dispatcher.emit(event.test.before, {});
+
+    let counter = 0;
+    event.dispatcher.emit(event.step.started, { name: 'somethingNew' });
+    try {
+      recorder.add(() => {
+        counter++;
+        if (counter < 3) {
+          throw new Error();
+        }
+      });
+      await recorder.promise();
+    } catch (e) {
+      recorder.catchWithoutStop((err) => {
+      });
+    }
+
+    counter.should.equal(1);
+    // expects to retry only once
+  });
+
   it('should not retry session', async () => {
     retryFailedStep({ retries: 1, minTimeout: 1 });
-    event.dispatcher.emit(event.test.before);
-
+    event.dispatcher.emit(event.test.before, {});
+    event.dispatcher.emit(event.step.started, { name: 'click' });
     let counter = 0;
 
     try {