Repository: Codeception/CodeceptJS (js)

Issue:
retry() method set retries globally, not only for currently executed step
I see that `retry()` option is set globally, not only for current step.

code:
```
  I.amOnPage("https://www.google.com");
  I.retry(6).see("Google");
  I.waitForText("Not found", 5);
```

and form above, I expect, that: `I.waitForText("Not found", 5);` will run only once, but this is what I see (run with `--verbose` flag):
```
    Emitted | step.start (I see "Google")
    I see "Google"
    Emitted | step.passed (I see "Google")
    Emitted | step.finish (I see "Google")
    Emitted | step.start (I wait for text "Not found", 5)
    I wait for text "Not found", 5
    [1] Retrying... Attempt #2
    [1] Retrying... Attempt #3
    [1] Retrying... Attempt #4
    [1] Retrying... Attempt #5
    [1] Retrying... Attempt #6
^C%  
```

is this behavior expected?

### Details

* CodeceptJS version: `2.3.1`
* NodeJS Version: `10.16.3`
* Operating System: macOS
* Puppeteer `1.19.0`


Pull request:
Fixed retry single step
Fixes #1891 

Patch:
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/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({


Test patch:
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/retry_helper.js b/test/data/sandbox/retry_helper.js
index b6081c86..c1ca038c 100644
--- a/test/data/sandbox/retry_helper.js
+++ b/test/data/sandbox/retry_helper.js
@@ -8,6 +8,10 @@ class Retry extends Helper {
   waitForFail(fn) {
     if (fn()) throw new Error('ups, error');
   }
+
+  asyncStep() {
+    return new Promise(resolve => setTimeout(resolve, 500));
+  }
 }
 
 module.exports = Retry;
diff --git a/test/runner/interface_test.js b/test/runner/interface_test.js
index f2408fef..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,14 @@ 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) => {
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.');