Repository: Codeception/CodeceptJS
Pull request #1212 (2018-09-18 00:44:26)

Allure reports
* **Allure reports introduced**
* Introduced **CodeceptJS plugins**
* Added `allure` plugin
* Improved detecting metasteps for BDD and nested steps
* Setting `within` as metastep
* Moved saving a screenshot on failure into `screenshotOnFail` plugin
* Added `stepByStepReporter` plugin which displays simple HTML report with screenshots for each taken step
* Include screenshots of failed tests into allure reports
* Per step screenshots for when `stepByStepReports` plugin is enabled

Documentation is coming

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 53
           

Complexity decreasing per file
==============================
+ lib/step.js  -3
+ lib/hooks.js  -1
         

Clones removed
==============
+ lib/helper/Puppeteer.js  -3
+ test/helper/webapi.js  -1
+ lib/helper/Nightmare.js  -1
+ lib/helper/Protractor.js  -3
+ lib/helper/WebDriverIO.js  -4
         
```


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


```diff

Complexity decreasing per file
==============================
+ lib/step.js  -3
+ lib/hooks.js  -1
         

Clones removed
==============
+ lib/helper/Puppeteer.js  -3
+ test/helper/webapi.js  -1
+ lib/helper/Nightmare.js  -1
+ lib/helper/Protractor.js  -3
+ lib/helper/WebDriverIO.js  -4
         
```


See the complete overview on [Codacy](https://app.codacy.com/app/DavertMik/CodeceptJS/pullRequest?prid=2206141&bid=8844715)
        
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 10
           

Complexity decreasing per file
==============================
+ lib/step.js  -3
+ lib/hooks.js  -1
         

Clones removed
==============
+ lib/helper/Puppeteer.js  -3
+ test/helper/Protractor_test.js  -1
+ test/helper/webapi.js  -2
+ lib/helper/Nightmare.js  -1
+ test/unit/scenario_test.js  -2
+ lib/helper/Protractor.js  -3
+ test/helper/WebDriverIO_test.js  -3
+ lib/helper/WebDriverIO.js  -4
         
```


See the complete overview on [Codacy](https://app.codacy.com/app/DavertMik/CodeceptJS/pullRequest?prid=2206141&bid=8844715)
        
DavertMik: ![selection_352](https://user-images.githubusercontent.com/220264/45676511-8e052800-bb3a-11e8-8cbb-db5f73de2add.png)

Added screenshot

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 2
           

Complexity increasing per file
==============================
- lib/step.js  3
- lib/output.js  1
         

Clones removed
==============
+ lib/helper/Puppeteer.js  -3
+ test/helper/Protractor_test.js  -1
+ test/helper/webapi.js  -2
+ lib/helper/Nightmare.js  -1
+ lib/helper/Protractor.js  -3
+ test/helper/WebDriverIO_test.js  -3
+ lib/helper/WebDriverIO.js  -4
         
```


See the complete overview on [Codacy](https://app.codacy.com/app/DavertMik/CodeceptJS/pullRequest?prid=2206141&bid=8844715)
        
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 4
           

Complexity increasing per file
==============================
- lib/step.js  3
- lib/output.js  1
         

Clones removed
==============
+ lib/helper/Puppeteer.js  -3
+ test/helper/Protractor_test.js  -1
+ test/helper/webapi.js  -2
+ lib/helper/Nightmare.js  -1
+ lib/helper/Protractor.js  -3
+ test/helper/WebDriverIO_test.js  -3
+ lib/helper/WebDriverIO.js  -4
         
```


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

Patch:
diff --git a/bin/codecept.js b/bin/codecept.js
index 45c6386c..e3b44100 100755
--- a/bin/codecept.js
+++ b/bin/codecept.js
@@ -89,6 +89,7 @@ program.command('run [test]')
   .option('-c, --config [file]', 'configuration file to be used')
   .option('--features', 'run only *.feature files and skip tests')
   .option('--tests', 'run only JS test files and skip features')
+  .option('-p, --plugins <k=v,k2=v2,...>', 'enable plugins, comma-separated')
 
   // mocha options
   .option('--colors', 'force enabling of colors')
diff --git a/docs/api/container.md b/docs/api/container.md
index 3f0e2f6b..1c308d47 100644
--- a/docs/api/container.md
+++ b/docs/api/container.md
@@ -23,6 +23,7 @@ Clear container
 
 -   `newHelpers` **Any** 
 -   `newSupport` **Any** 
+-   `newPlugins`  
 
 ## create
 
@@ -45,6 +46,14 @@ Get all helpers or get a helper by name
 
 Get Mocha instance
 
+## plugins
+
+Get all plugins
+
+**Parameters**
+
+-   `name` **[string]** 
+
 ## support
 
 Get all support objects or get support object by name
diff --git a/docs/api/output.md b/docs/api/output.md
index a8119e18..4df9d016 100644
--- a/docs/api/output.md
+++ b/docs/api/output.md
@@ -47,14 +47,6 @@ Print a step
 
 -   `step`  
 
-# stepExecutionTime
-
-Print a step execution time
-
-**Parameters**
-
--   `step`  
-
 # success
 
 Print a successful message
diff --git a/docs/helpers/Appium.md b/docs/helpers/Appium.md
index e41a5646..218a700c 100644
--- a/docs/helpers/Appium.md
+++ b/docs/helpers/Appium.md
@@ -1,6 +1,6 @@
 # Appium
 
-Appium helper extends [WebDriverIO](http://codecept.io/helpers/WebDriverIO/) helper.
+Appium helper extends [WebriverIO](http://codecept.io/helpers/WebDriverIO/) helper.
  It supports all browser methods and also includes special methods for mobile apps testing.
  You can use this helper to test Web on desktop and mobile devices and mobile apps.
 
diff --git a/docs/hooks.md b/docs/hooks.md
index 65aa22a3..7c255413 100644
--- a/docs/hooks.md
+++ b/docs/hooks.md
@@ -167,10 +167,57 @@ exports.config = {
 
 **Note**: The `bootstrapAll` and `teardownAll` hooks are only called when using [Multiple Execution](http://codecept.io/advanced/#multiple-execution).
 
+## Plugins
+
+Plugins allow to use CodeceptJS internal API to extend functionality. Use internal event dispatcher, container, output, promise recorder, to create your own reporters, test listeners, etc.
+
+CodeceptJS includes [built-in plugins](https://codecept.io/plugins/) which extend basic functionality and can be turned on and off on purpose. Taking them as [examples](https://github.com/Codeception/CodeceptJS/tree/master/lib/plugin) you can develop your custom plugins.
+
+A plugin is a basic JS module returning a function. Plugins can have individual configs which are passed into this function:
+
+```js
+const defaultConfig = {
+  someDefaultOption: true
+}
+
+module.exports = function(config) {
+  config = Object.assign(defaultConfig, config);
+  // do stuff
+}
+```
+
+Plugin can register event listeners or hook into promise chain with recorder. See [API reference](https://github.com/Codeception/CodeceptJS/tree/master/lib/helper).
+
+To enable your custom plugin in config add it to `plugins` section. Specify path to node module using `require`.
+
+```js
+"plugins": {
+  "myPlugin": {
+    "require": "./path/to/my/module",
+    "enabled": true
+  }
+}
+```
+
+* `require` - specifies relative path to a plugin file. Path is relative to config file.
+* `enabled` - to enable this plugin.
+
+If a plugin is disabled (`enabled` is not set or false) this plugin can be enabled from command line:
+
+```
+./node_modules/.bin/codeceptjs run --plugin myPlugin
+```
+
+Several plugins can be enabled as well:
+
+```
+./node_modules/.bin/codeceptjs run --plugin myPlugin,allure
+```
+
+
 ## Custom Hooks
 
-To extend internal CodeceptJS functionality you can use hooks.
-CodeceptJS provides API to connect to its internal event dispatcher, container, output, promise recorder, so you could hook into it to create your own reporters, test listeners, etc.
+*(deprecated, use [plugins](#plugins))*
 
 Hooks are JavaScript files same as for bootstrap and teardown, which can be registered inside `hooks` section of config. Unlike `bootstrap` you can have multiple hooks registered:
 
@@ -182,10 +229,12 @@ Hooks are JavaScript files same as for bootstrap and teardown, which can be regi
 ]
 ```
 
-Inside those JS files you can use CodeceptJS API to access its internals.
+Inside those JS files you can use CodeceptJS API (see below) to access its internals.
 
 ## API
 
+**Use local CodeceptJS installation to get access to `codeceptjs` module**
+
 CodeceptJS provides an API which can be loaded via `require('codeceptjs')` when CodeceptJS is installed locally.
 These internal objects are available:
 
@@ -201,19 +250,6 @@ These internal objects are available:
 [API reference](https://github.com/Codeception/CodeceptJS/tree/master/docs/api) is available on GitHub.
 Also please check the source code of corresponding modules.
 
-### Config
-
-CodeceptJS config can be accessed from `require('codeceptjs').config.get()`:
-
-```js
-
-let config = require('codeceptjs').config.get();
-
-if (config.myKey == 'value') {
-  // run hook
-}
-```
-
 ### Event Listeners
 
 CodeceptJS provides a module with [event dispatcher and set of predefined events](https://github.com/Codeception/CodeceptJS/blob/master/lib/event.js).
@@ -357,6 +393,9 @@ let support = container.support();
 
 // get support object by name
 let UserPage = container.support('UserPage');
+
+// get all registered plugins
+let plugins = container.plugins();
 ```
 
 New objects can also be added to container in runtime:
@@ -380,6 +419,19 @@ Container also contains current Mocha instance:
 let mocha = container.mocha();
 ```
 
+### Config
+
+CodeceptJS config can be accessed from `require('codeceptjs').config.get()`:
+
+```js
+
+let config = require('codeceptjs').config.get();
+
+if (config.myKey == 'value') {
+  // run hook
+}
+```
+
 ## Custom Runner
 
 CodeceptJS can be imported and used in custom runners.
diff --git a/docs/plugins.md b/docs/plugins.md
new file mode 100644
index 00000000..7ff87843
--- /dev/null
+++ b/docs/plugins.md
@@ -0,0 +1,129 @@
+# allure
+
+Allure reporter
+
+![](https://user-images.githubusercontent.com/220264/45676511-8e052800-bb3a-11e8-8cbb-db5f73de2add.png)
+
+Enables Allure reporter.
+
+### Usage
+
+To start please install `allure-commandline` package (which requires Java 8)
+
+    npm install -g allure-commandline --save-dev
+
+Add this plugin to config file:
+
+```js
+"plugins": {
+    "allure": {}
+}
+```
+
+Run tests with allure plugin enabled:
+
+    codeceptjs run --plugin allure
+
+By default, allure reports are saved to `output` directory.
+Launch Allure server and see the report like on a screenshot above:
+
+    allure serve output
+
+### Configuration
+
+-   `targetDir` - a directory where allure reports should be stored. Standard output directory is set by default.
+
+**Parameters**
+
+-   `config` **Any** 
+
+# retryFailedStep
+
+Retries each failed step in a test.
+
+Add this plugin to config file:
+
+```js
+"plugins": {
+    "runFailedStep": {
+       "enabled": true
+    }
+}
+```
+
+## Configuration:
+
+-   `retries` - number of retries (by default 5),
+-   `when` - function, when to perform a retry (accepts error as parameter)
+-   `factor` - The exponential factor to use. Default is 2.
+-   `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.
+
+This plugin is very basic so it's recommended to improve it to match your custom needs.
+
+**Parameters**
+
+-   `config`  
+
+# screenshotOnFail
+
+Creates screenshot on failure. Screenshot is saved into `output` directory.
+
+Initially this functionality was part of corresponding helper but has been moved into plugin since 1.4
+
+This plugin is **enabled by default**.
+
+## Configuration
+
+Configuration can either be taken from a corresponding helper (deprecated) or a from plugin config (recommended).
+
+```js
+"plugins": {
+   "screenshotOnFail": {
+     "enabled": true
+   }
+}
+```
+
+Possible config options:
+
+-   `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
+-   `fullPageScreenshots`: make full page screenshots. Default: false.
+
+**Parameters**
+
+-   `config`  
+
+# stepByStepReport
+
+![](https://user-images.githubusercontent.com/220264/45696671-c58cc800-bb6c-11e8-9bd9-28396805de2b.png)
+
+Generates step by step report for a test.
+After each step in a test a screenshot is created. After test executed screenshots are combined into slideshow.
+By default, reports are generated only for failed tests.
+
+## Configuration
+
+```js
+"plugins": {
+   "stepByStepReport": {
+     "enabled": true
+   }
+}
+```
+
+Possible config options:
+
+-   `deleteSuccessful`: do not save screenshots for successfully executed tests. Default: true.
+-   `animateSlides`: should animation for slides to be used. Default: true.
+-   `ignoreSteps`: steps to ignore in report. Array of RegExps is expected. Recommended to skip `grab*` and `wait*` steps.
+-   `fullPageScreenshots`: should full page screenshots be used. Default: false.
+
+## Allure Reports
+
+If Allure plugin is enabled this plugin attaches each saved screenshot to allure report.
+
+**Parameters**
+
+-   `config` **Any** 
diff --git a/docs/reports.md b/docs/reports.md
index 62922a6e..5dd21d76 100644
--- a/docs/reports.md
+++ b/docs/reports.md
@@ -1,6 +1,8 @@
 # Reporters
 
-## Cli (default)
+## Cli
+
+(default)
 
 By default CodeceptJS provides cli reporter with console output.
 Test names and failures will be printed to screen.
@@ -81,6 +83,61 @@ GitHub --
 
 Please use verbose output when reporting issues to GitHub.
 
+
+## Allure
+
+(recommended)
+
+
+[Allure reporter](http://allure.qatools.ru/#) is a tool to store and display test reports.
+It provides nice web UI which contains all important information on test execution.
+CodeceptJS has built-in support for Allure reports. Inside reports you will have all steps, substeps and screenshots.
+
+![](https://user-images.githubusercontent.com/220264/45676511-8e052800-bb3a-11e8-8cbb-db5f73de2add.png)
+
+*Disclaimer: Allure is a standalone tool. Please refer to [Allure documentation](https://docs.qameta.io/allure/) to learn more about using Allure reports.*
+
+Allure requires **Java 8** to work. Then Allure can be installed via NPM:
+
+```
+npm install -g allure-commandline --save-dev
+```
+
+Add [Allure plugin](https://codecept.io/plugins/#allure) in config under `plugins` section.
+
+```js
+"plugins": {
+    "allure": {
+    }
+}
+```
+
+Run tests with allure plugin enabled:
+
+```
+codeceptjs run --plugin allure
+```
+
+(optionally) To enable allure plugin permanently include `"enabled": true` into plugin config:
+
+
+```js
+"plugins": {
+    "allure": {
+      "enabled": true
+    }
+}
+```
+
+Launch Allure server and see the report like on a screenshot above:
+
+```
+allure serve output
+```
+
+Allure reporter aggregates data from other plugins like [*stepByStepReport*](https://codecept.io/plugins/#stepByStepReport) and [*screenshotOnFail*](https://codecept.io/plugins/#screenshotOnFail)
+
+
 ## XML
 
 Use default xunit reporter of Mocha to print xml reports. Provide `--reporter xunit` to get the report to screen.
diff --git a/examples/codecept.json b/examples/codecept.json
index a2dba799..c29e0e76 100644
--- a/examples/codecept.json
+++ b/examples/codecept.json
@@ -3,6 +3,7 @@
   "helpers": {
     "Puppeteer": {
       "url": "http://localhost",
+      "browser": "chrome",
       "restart": true,
       "windowSize": "1600x1200"
     },
@@ -28,6 +29,12 @@
       "./step_definitions/steps.js"
     ]
   },
+  "plugins": {
+    "allure": {
+      "enabled": false
+    },
+    "stepByStepReport": {}
+  },
   "tests": "./*_test.js",
   "timeout": 10000,
   "multiple": {
diff --git a/examples/features/basic.feature b/examples/features/basic.feature
index 92fb3599..2ff4a30d 100644
--- a/examples/features/basic.feature
+++ b/examples/features/basic.feature
@@ -5,3 +5,4 @@ Feature: Business rules
 
   Scenario: do something
     Given I have a defined step
+    When I open GitHub
diff --git a/examples/pages/Login.js b/examples/pages/Login.js
index 67b06d9a..caac4e8f 100644
--- a/examples/pages/Login.js
+++ b/examples/pages/Login.js
@@ -8,3 +8,5 @@ module.exports = {
     I.click('Sign in');
   },
 };
+
+Object.setPrototypeOf(module.exports, class Login {}.prototype);
diff --git a/examples/pages/Smth.js b/examples/pages/Smth.js
index a38fce5a..a69c59c0 100644
--- a/examples/pages/Smth.js
+++ b/examples/pages/Smth.js
@@ -1,6 +1,8 @@
 const I = actor();
 const loginPage = require('./Login');
 
+class Smth {}
+
 module.exports = {
 
   openGitHub() {
@@ -11,5 +13,6 @@ module.exports = {
     this.openGitHub();
     loginPage.login('something@totest.com', '1234356');
   },
-
 };
+
+Object.setPrototypeOf(module.exports, Smth.prototype);
diff --git a/examples/step_definitions/steps.js b/examples/step_definitions/steps.js
index db3d2960..3ad51668 100644
--- a/examples/step_definitions/steps.js
+++ b/examples/step_definitions/steps.js
@@ -1,4 +1,6 @@
 const assert = require('assert');
+const Smth = require('../pages/Smth');
+
 
 const I = actor();
 // Add in your custom step files
@@ -13,6 +15,11 @@ Given('Open google', () => {
   I.amOnPage('https://www.google.ru/webhp?hl=ru&sa=X&ved=0ahUKEwjY7rWfrPjbAhUHFiwKHas5DCMQPAgD');
 });
 
+Given('I open GitHub', () => {
+  Smth.openGitHub();
+  I.dontSee('Ups');
+});
+
 Then('check link', async () => {
   // From "features\lawPage.feature" {"line":73,"column":7}
   const link = await I.grabAttributeFrom({ css: '#gbw > div > div > div.gb_qe.gb_R.gb_Pg.gb_Fg > div:nth-child(2) > a' }, 'href');
diff --git a/gulpfile.js b/gulpfile.js
index dd6302dd..fc4cc757 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -23,6 +23,10 @@ gulp.task('docs', () => {
       .pipe(documentation({ filename: baseName + '.md', shallow: true, format: 'md'}))
       .pipe(gulp.dest(path.join(cpath, 'docs/api')));
   });
+
+  gulp.src(path.join(cpath, 'lib/plugin/*.js'))
+    .pipe(documentation({ shallow: true, filename: 'plugins.md', format: 'md' }))
+    .pipe(gulp.dest(path.join(cpath, 'docs')));
 });
 
 
diff --git a/lib/actor.js b/lib/actor.js
index af1206e2..f45323ec 100644
--- a/lib/actor.js
+++ b/lib/actor.js
@@ -63,8 +63,10 @@ function recordStep(step, args) {
 
   // run step inside promise
   recorder.add(task, () => {
-    event.emit(event.step.started, step);
-    step.startTime = Date.now();
+    if (!step.startTime) { // step can be retries
+      event.emit(event.step.started, step);
+      step.startTime = Date.now();
+    }
     return val = step.run(...args);
   });
 
diff --git a/lib/codecept.js b/lib/codecept.js
index 7b393934..adc0aa68 100644
--- a/lib/codecept.js
+++ b/lib/codecept.js
@@ -134,6 +134,7 @@ class Codecept {
       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);
     mocha.run(() => {
       const done = () => {
         event.emit(event.all.result, this);
diff --git a/lib/command/run-multiple.js b/lib/command/run-multiple.js
index e728210f..00bbc7dd 100644
--- a/lib/command/run-multiple.js
+++ b/lib/command/run-multiple.js
@@ -14,7 +14,7 @@ const clearString = require('../utils').clearString;
 const runner = path.join(__dirname, '/../../bin/codecept');
 let config;
 const childOpts = {};
-const copyOptions = ['steps', 'reporter', 'verbose', 'config', 'reporter-options', 'grep', 'fgrep', 'debug'];
+const copyOptions = ['override', 'steps', 'reporter', 'verbose', 'config', 'reporter-options', 'grep', 'fgrep', 'debug'];
 
 // codeceptjs run:multiple smoke:chrome regression:firefox - will launch smoke run in chrome and regression in firefox
 // codeceptjs run:multiple smoke:chrome regression - will launch smoke run in chrome and regression in firefox and chrome
diff --git a/lib/command/run.js b/lib/command/run.js
index d0ee08b5..bca890c0 100644
--- a/lib/command/run.js
+++ b/lib/command/run.js
@@ -34,7 +34,6 @@ module.exports = function (test, options) {
     codecept = new Codecept(config, options);
     codecept.init(testRoot, (err) => {
       if (err) throw new Error(`Error while running bootstrap file :${err}`);
-      event.emit(event.all.before, this);
       codecept.loadTests();
       codecept.run(test);
     });
diff --git a/lib/config.js b/lib/config.js
index 6aa17846..a1046406 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -13,6 +13,11 @@ const defaultConfig = {
   teardown: null,
   hooks: [],
   gherkin: {},
+  plugins: {
+    screenshotOnFail: {
+      enabled: true, // will be disabled by default in 2.0
+    },
+  },
 };
 
 let config = {};
diff --git a/lib/container.js b/lib/container.js
index 153aa611..5b81c27b 100644
--- a/lib/container.js
+++ b/lib/container.js
@@ -7,6 +7,7 @@ const recorder = require('./recorder');
 let container = {
   helpers: {},
   support: {},
+  plugins: {},
   mocha: {},
   translation: {},
 };
@@ -31,9 +32,23 @@ class Container {
     container.helpers = createHelpers(config.helpers || {});
     container.translation = loadTranslation(config.translation || null);
     container.support = createSupportObjects(config.include || {});
+    container.plugins = createPlugins(config.plugins || {}, opts);
     if (config.gherkin) loadGherkinSteps(config.gherkin.steps || []);
   }
 
+  /**
+   * Get all plugins
+   *
+   * @api
+   * @param {string} [name]
+   */
+  static plugins(name) {
+    if (!name) {
+      return container.plugins;
+    }
+    return container.plugins[name];
+  }
+
   /**
    * Get all support objects or get support object by name
    *
@@ -94,9 +109,10 @@ class Container {
    * @param {*} newHelpers
    * @param {*} newSupport
    */
-  static clear(newHelpers, newSupport) {
+  static clear(newHelpers, newSupport, newPlugins) {
     container.helpers = newHelpers || {};
     container.support = newSupport || {};
+    container.plugins = newPlugins || {};
     container.translation = loadTranslation();
   }
 }
@@ -187,6 +203,33 @@ function createSupportObjects(config) {
   return objects;
 }
 
+function createPlugins(config, options = {}) {
+  const plugins = {};
+
+  const enabledPluginsByOptions = (options.plugins || '').split(',');
+  for (const pluginName in config) {
+    if (!config[pluginName]) config[pluginName] = {};
+    if (!config[pluginName].enabled && (enabledPluginsByOptions.indexOf(pluginName) < 0)) {
+      continue; // plugin is disabled
+    }
+    let module;
+    try {
+      if (config[pluginName].require) {
+        module = config[pluginName].require;
+        if (module.startsWith('.')) { // local
+          module = path.resolve(global.codecept_dir, module); // custom plugin
+        }
+      } else {
+        module = `./plugin/${pluginName}`;
+      }
+      plugins[pluginName] = require(module)(config);
+    } catch (err) {
+      throw new Error(`Could not load plugin ${pluginName} from module '${module}':\n${err.message}`);
+    }
+  }
+  return plugins;
+}
+
 function getSupportObject(config, name) {
   const module = config[name];
   if (typeof module === 'string') {
diff --git a/lib/helper.js b/lib/helper.js
index a44e0e6c..4273ebb1 100644
--- a/lib/helper.js
+++ b/lib/helper.js
@@ -1,5 +1,5 @@
 const container = require('./container');
-const debug = require('./output').debug;
+const output = require('./output');
 
 /**
  * @inner
@@ -148,11 +148,11 @@ class Helper {
    * Print debug message to console (outputs only in debug mode)
    */
   debug(msg) {
-    debug(msg);
+    output.debug(msg);
   }
 
   debugSection(section, msg) {
-    debug(`[${section}] ${msg}`);
+    output.debug(`[${section}] ${msg}`);
   }
 }
 
diff --git a/lib/helper/Nightmare.js b/lib/helper/Nightmare.js
index 53a90840..6ba30b15 100644
--- a/lib/helper/Nightmare.js
+++ b/lib/helper/Nightmare.js
@@ -1054,20 +1054,7 @@ class Nightmare extends Helper {
   }
 
   async _failed(test) {
-    const promisesList = [];
-    if (withinStatus !== false) promisesList.push(this._withinEnd());
-    if (!this.options.disableScreenshots) {
-      let fileName = clearString(test.title);
-      if (test.ctx && test.ctx.test && test.ctx.test.type === 'hook') fileName = clearString(`${test.title}_${test.ctx.test.title}`);
-      if (this.options.uniqueScreenshotNames) {
-        const uuid = test.uuid || test.ctx.test.uuid;
-        fileName = `${fileName.substring(0, 10)}_${uuid}.failed.png`;
-      } else {
-        fileName += '.failed.png';
-      }
-      promisesList.push(this.saveScreenshot(fileName, true));
-    }
-    return Promise.all(promisesList);
+    if (withinStatus !== false) await this._withinEnd();
   }
 
   /**
diff --git a/lib/helper/Protractor.js b/lib/helper/Protractor.js
index 516ad185..6e64b272 100644
--- a/lib/helper/Protractor.js
+++ b/lib/helper/Protractor.js
@@ -267,25 +267,6 @@ class Protractor extends Helper {
 
   async _failed(test, err) {
     await this._withinEnd();
-    if (this.options.disableScreenshots) return;
-    let fileName = clearString(test.title);
-    if (test.ctx && test.ctx.test && test.ctx.test.type === 'hook') fileName = clearString(`${test.title}_${test.ctx.test.title}`);
-    if (this.options.uniqueScreenshotNames) {
-      const uuid = test.uuid || test.ctx.test.uuid;
-      fileName = `${fileName.substring(0, 10)}_${uuid}.failed.png`;
-    } else {
-      fileName += '.failed.png';
-    }
-    return this.saveScreenshot(fileName, true).catch((err) => {
-      if (err &&
-          err.type &&
-          err.type === 'RuntimeError' &&
-          err.message &&
-          (err.message.indexOf('was terminated due to') > -1 || err.message.indexOf('no such window: target window already closed') > -1)
-      ) {
-        this.isRunning = false;
-      }
-    });
   }
 
   async _finishTest() {
diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js
index 1ab432cd..06cd7403 100644
--- a/lib/helper/Puppeteer.js
+++ b/lib/helper/Puppeteer.js
@@ -185,7 +185,7 @@ class Puppeteer extends Helper {
   async _before() {
     recorder.retry({
       retries: 3,
-      when: err => err.message.indexOf('Cannot find context with specified id') > -1,
+      when: err => err.message.indexOf('context') > -1, // ignore context errors
     });
     if (this.options.restart && !this.options.manualStart) return this._startBrowser();
     if (!this.isRunning && !this.options.manualStart) return this._startBrowser();
@@ -1330,19 +1330,7 @@ class Puppeteer extends Helper {
   }
 
   async _failed(test) {
-    const promisesList = [];
     await this._withinEnd();
-    if (!this.options.disableScreenshots) {
-      let fileName = clearString(test.title);
-      if (test.ctx && test.ctx.test && test.ctx.test.type === 'hook') fileName = clearString(`${test.title}_${test.ctx.test.title}`);
-      if (this.options.uniqueScreenshotNames) {
-        const uuid = test.uuid || test.ctx.test.uuid;
-        fileName = `${fileName.substring(0, 10)}_${uuid}.failed.png`;
-      } else {
-        fileName += '.failed.png';
-      }
-      await this.saveScreenshot(fileName, true);
-    }
   }
 
   /**
diff --git a/lib/helper/WebDriverIO.js b/lib/helper/WebDriverIO.js
index f8dff35a..babb8bd9 100644
--- a/lib/helper/WebDriverIO.js
+++ b/lib/helper/WebDriverIO.js
@@ -391,26 +391,6 @@ class WebDriverIO extends Helper {
 
   async _failed(test) {
     if (isWithin()) await this._withinEnd();
-    if (!this.options.disableScreenshots) {
-      let fileName = clearString(test.title);
-      if (test.ctx && test.ctx.test && test.ctx.test.type === 'hook') fileName = clearString(`${test.title}_${test.ctx.test.title}`);
-      if (this.options.uniqueScreenshotNames) {
-        const uuid = test.uuid || test.ctx.test.uuid;
-        fileName = `${fileName.substring(0, 10)}_${uuid}.failed.png`;
-      } else {
-        fileName += '.failed.png';
-      }
-      return this.saveScreenshot(fileName, this.options.fullPageScreenshots).catch((err) => {
-        if (err &&
-            err.type &&
-            err.type === 'RuntimeError' &&
-            err.message &&
-            (err.message.indexOf('was terminated due to') > -1 || err.message.indexOf('no such window: target window already closed') > -1)
-        ) {
-          this.isRunning = false;
-        }
-      });
-    }
   }
 
   async _withinBegin(locator) {
diff --git a/lib/hooks.js b/lib/hooks.js
index 5bcd95c7..66d51af5 100644
--- a/lib/hooks.js
+++ b/lib/hooks.js
@@ -26,6 +26,17 @@ module.exports = function (hook, done, stage) {
   if (done) done();
 };
 
+function loadCustomHook(module) {
+  try {
+    if (module.startsWith('.')) {
+      module = fsPath.resolve(global.codecept_dir, module); // custom plugin
+    }
+    return require(module);
+  } catch (err) {
+    throw new Error(`Could not load hook from module '${module}':\n${err.message}`);
+  }
+}
+
 function callSync(callable, done) {
   if (isAsync(callable)) {
     callable(done);
diff --git a/lib/interfaces/gherkin.js b/lib/interfaces/gherkin.js
index 7084824d..3763bbae 100644
--- a/lib/interfaces/gherkin.js
+++ b/lib/interfaces/gherkin.js
@@ -29,8 +29,10 @@ module.exports = (text) => {
     for (const step of steps) {
       const metaStep = new Step.MetaStep(null, step.text);
       metaStep.actor = step.keyword.trim();
-      metaStep.humanize = () => step.text;
-      const setMetaStep = step => step.metaStep = metaStep;
+      const setMetaStep = (step) => {
+        if (step.metaStep) step = step.metaStep; // assign metastep to metastep for nested steps
+        step.metaStep = metaStep;
+      };
       const fn = matchStep(step.text);
       if (step.argument) {
         fn.params.push(step.argument);
diff --git a/lib/output.js b/lib/output.js
index 58285122..a58278c1 100644
--- a/lib/output.js
+++ b/lib/output.js
@@ -1,5 +1,5 @@
 const colors = require('chalk');
-const symbols = require('mocha/lib/reporters/base').symbols;
+const figures = require('figures');
 
 const styles = {
   error: colors.bgRed.white.bold,
@@ -23,7 +23,7 @@ module.exports = {
   /**
    * Set or return current verbosity level
    */
-  level: (level) => {
+  level(level) {
     if (level !== undefined) outputLevel = level;
     return outputLevel;
   },
@@ -32,7 +32,7 @@ module.exports = {
    * Print information for a process
    * Used in multiple-run
    */
-  process: (process) => {
+  process(process) {
     if (process) outputProcess = `[${process}]`;
     return outputProcess;
   },
@@ -40,33 +40,37 @@ module.exports = {
   /**
    * Print information in --debug mode
    */
-  debug: (msg) => {
+  debug(msg) {
     if (outputLevel >= 2) {
-      print(' '.repeat(this.stepShift), styles.debug(`> ${msg}`));
+      print(' '.repeat(this.stepShift), styles.debug(truncate(`${figures.pointerSmall} ${msg}`, this.spaceShift)));
     }
   },
 
   /**
    * Print information in --verbose mode
    */
-  log: (msg) => {
-    if (outputLevel >= 3) print(' '.repeat(this.stepShift), styles.log(`  ${msg}`));
+  log(msg) {
+    if (outputLevel >= 3) print(' '.repeat(this.stepShift), styles.log(truncate(`   ${msg}`, this.spaceShift)));
   },
 
   /**
    * Print error
    */
-  error: (msg) => {
+  error(msg) {
     print(styles.error(msg));
   },
 
   /**
    * Print a successful message
    */
-  success: (msg) => {
+  success(msg) {
     print(styles.success(msg));
   },
 
+  plugin(name, msg) {
+    this.debug(`<${name}> ${msg}`);
+  },
+
   /**
    * Print a step
    */
@@ -76,40 +80,14 @@ module.exports = {
     let stepLine = step.toString();
     if (step.metaStep) {
       if (outputLevel < 2) return;
-      stepLine = ' '.repeat(2) + colors.green(stepLine);
+      this.stepShift += 2;
+      stepLine = colors.green(truncate(stepLine, this.spaceShift));
     }
     if (step.comment) {
       stepLine += colors.grey(step.comment.split('\n').join('\n' + ' '.repeat(4))); // eslint-disable-line
     }
-    if (step.isMetaStep && step.isMetaStep() && outputLevel === 2) {
-      stepLine += '\n'; // meta steps don't have execution time
-    }
-    const sym = ' ';
-    if (outputLevel === 2) {
-      newline = false;
-      return process.stdout.write(`${' '.repeat(this.stepShift)} ${sym} ${stepLine}`);
-    }
-    print(' '.repeat(this.stepShift), `${sym} ${stepLine}`);
-  },
-
-  /**
-   * Print a step execution time
-   */
-  stepExecutionTime(step) {
-    if (outputLevel < 2) return;
-    if (!step) return;
-
-    const time = (step.endTime - step.startTime) / 1000;
-
-    if (Number.isNaN(time)) return;
-
-    if (outputLevel === 2) {
-      newline = true;
-      process.stdout.write(`${styles.debug(` ("${time} sec")\n`)}`);
-      return;
-    }
 
-    this.log(`Step finished in ${time} sec`);
+    print(' '.repeat(this.stepShift), truncate(stepLine, this.spaceShift));
   },
 
   suite: {
@@ -121,39 +99,37 @@ module.exports = {
   },
 
   test: {
-    started: (test) => {
+    started(test) {
       print(`  ${colors.magenta.bold(test.title)}`);
     },
-    passed: (test) => {
-      print(`  ${colors.green.bold(symbols.ok)} ${test.title} ${colors.grey(`in ${test.duration}ms`)}`);
+    passed(test) {
+      print(`  ${colors.green.bold(figures.tick)} ${test.title} ${colors.grey(`in ${test.duration}ms`)}`);
     },
-    failed: (test) => {
-      print(`  ${colors.red.bold(symbols.err)} ${test.title} ${colors.grey(`in ${test.duration}ms`)}`);
+    failed(test) {
+      print(`  ${colors.red.bold(figures.cross)} ${test.title} ${colors.grey(`in ${test.duration}ms`)}`);
     },
-    skipped: (test) => {
+    skipped(test) {
       print(`  ${colors.yellow.bold('S')} ${test.title}`);
     },
   },
 
   scenario: {
-    started: (test) => {
-
-    },
-    passed: (test) => {
-      print(`  ${colors.green.bold(`${symbols.ok} OK`)} ${colors.grey(`in ${test.duration}ms`)}`);
+    started(test) {},
+    passed(test) {
+      print(`  ${colors.green.bold(`${figures.tick} OK`)} ${colors.grey(`in ${test.duration}ms`)}`);
       print();
     },
-    failed: (test) => {
-      print(`  ${colors.red.bold(`${symbols.err} FAILED`)} ${colors.grey(`in ${test.duration}ms`)}`);
+    failed(test) {
+      print(`  ${colors.red.bold(`${figures.cross} FAILED`)} ${colors.grey(`in ${test.duration}ms`)}`);
       print();
     },
   },
 
-  say: (message) => {
+  say(message) {
     if (outputLevel >= 1) print(`   ${colors.cyan.bold(message)}`);
   },
 
-  result: (passed, failed, skipped, duration) => {
+  result(passed, failed, skipped, duration) {
     let style = colors.bgGreen;
     let msg = ` ${passed || 0} passed`;
     let status = style.bold('  OK ');
@@ -181,9 +157,17 @@ function print(...msg) {
     console.log();
     newline = true;
   }
+
   console.log.apply(this, msg);
 }
 
-function ind() {
-  return '  ';
+function truncate(msg, gap = 0) {
+  if (msg.indexOf('\n') > 0) {
+    return msg; // don't cut multi line steps
+  }
+  const width = (process.stdout.columns || 200) - gap - 1;
+  if (msg.length > width) {
+    msg = msg.substr(0, width - 1) + figures.ellipsis;
+  }
+  return msg;
 }
diff --git a/lib/plugin/allure.js b/lib/plugin/allure.js
new file mode 100644
index 00000000..21680d14
--- /dev/null
+++ b/lib/plugin/allure.js
@@ -0,0 +1,141 @@
+const event = require('../event');
+const Allure = require('allure-js-commons');
+
+const defaultConfig = {
+  outputDir: global.output_dir,
+};
+
+/**
+ * Allure reporter
+ *
+ * ![](https://user-images.githubusercontent.com/220264/45676511-8e052800-bb3a-11e8-8cbb-db5f73de2add.png)
+ *
+ * Enables Allure reporter.
+ *
+ * ### Usage
+ *
+ * To start please install `allure-commandline` package (which requires Java 8)
+ *
+ * ```
+ * npm install -g allure-commandline --save-dev
+ * ```
+ *
+ * Add this plugin to config file:
+ *
+ * ```js
+ * "plugins": {
+ *     "allure": {}
+ * }
+ * ```
+ *
+ * Run tests with allure plugin enabled:
+ *
+ * ```
+ * codeceptjs run --plugin allure
+ * ```
+ *
+ * By default, allure reports are saved to `output` directory.
+ * Launch Allure server and see the report like on a screenshot above:
+ *
+ * ```
+ * allure serve output
+ * ```
+ *
+ * ### Configuration
+ *
+ * * `targetDir` - a directory where allure reports should be stored. Standard output directory is set by default.
+ *
+ *
+ * @param {*} config
+ */
+module.exports = (config) => {
+  config = Object.assign(defaultConfig, config);
+
+  const reporter = new Allure();
+  reporter.setOptions({ targetDir: config.outputDir });
+
+  let currentMetaStep = [];
+  let currentStep;
+
+  this.addAttachment = (name, buffer, type) => {
+    reporter.addAttachment(name, buffer, type);
+  };
+
+  event.dispatcher.on(event.suite.before, (suite) => {
+    reporter.startSuite(suite.fullTitle());
+  });
+
+  event.dispatcher.on(event.suite.after, () => {
+    reporter.endSuite();
+  });
+
+  event.dispatcher.on(event.test.before, (test) => {
+    reporter.startCase(test.title);
+    currentStep = null;
+  });
+
+  event.dispatcher.on(event.test.failed, (test, err) => {
+    if (currentStep) reporter.endStep('failed');
+    if (currentMetaStep.length) {
+      currentMetaStep.forEach(() => reporter.endStep('failed'));
+      currentMetaStep = [];
+    }
+    reporter.endCase('failed', err);
+  });
+
+  event.dispatcher.on(event.test.passed, (test) => {
+    if (currentStep) reporter.endStep('passed');
+    if (currentMetaStep.length) {
+      currentMetaStep.forEach(() => reporter.endStep('passed'));
+      currentMetaStep = [];
+    }
+    reporter.endCase('passed');
+  });
+
+  event.dispatcher.on(event.step.started, (step) => {
+    startMetaStep(step.metaStep);
+    if (currentStep !== step) {
+      reporter.startStep(step.toString());
+      currentStep = step;
+    }
+  });
+
+  event.dispatcher.on(event.step.passed, (step) => {
+    if (currentStep === step) {
+      reporter.endStep('passed');
+      currentStep = null;
+    }
+  });
+
+  event.dispatcher.on(event.step.failed, (step) => {
+    if (currentStep === step) {
+      reporter.endStep('failed');
+      currentStep = null;
+    }
+  });
+
+  let maxLevel;
+  function finishMetastep(level) {
+    const metaStepsToFinish = currentMetaStep.splice(maxLevel - level);
+    metaStepsToFinish.forEach(() => reporter.endStep('passed'));
+  }
+
+  function startMetaStep(metaStep, level = 0) {
+    maxLevel = level;
+    if (!metaStep) {
+      finishMetastep(0);
+      maxLevel--;
+      return;
+    }
+
+    startMetaStep(metaStep.metaStep, level + 1);
+
+    if (metaStep.toString() !== currentMetaStep[maxLevel - level]) {
+      finishMetastep(level);
+      currentMetaStep.push(metaStep.toString());
+      reporter.startStep(metaStep.toString());
+    }
+  }
+
+  return this;
+};
diff --git a/lib/plugin/retryFailedStep.js b/lib/plugin/retryFailedStep.js
new file mode 100644
index 00000000..3c5b026e
--- /dev/null
+++ b/lib/plugin/retryFailedStep.js
@@ -0,0 +1,39 @@
+const event = require('../event');
+const recorder = require('../recorder');
+
+const defaultConfig = {
+  retries: 5,
+};
+
+/**
+ * Retries each failed step in a test.
+ *
+ * Add this plugin to config file:
+ *
+ * ```js
+ * "plugins": {
+ *     "runFailedStep": {
+ *        "enabled": true
+ *     }
+ * }
+ * ```
+ *
+ * ## Configuration:
+ *
+ * * `retries` - number of retries (by default 5),
+ * * `when` - function, when to perform a retry (accepts error as parameter)
+ * * `factor` - The exponential factor to use. Default is 2.
+ * * `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.
+ *
+ * This plugin is very basic so it's recommended to improve it to match your custom needs.
+ *
+ */
+module.exports = (config) => {
+  config = Object.assign(defaultConfig, config);
+
+  event.dispatcher.on(event.test.before, (test) => {
+    recorder.retry(config);
+  });
+};
diff --git a/lib/plugin/screenshotOnFail.js b/lib/plugin/screenshotOnFail.js
new file mode 100644
index 00000000..a73dcf48
--- /dev/null
+++ b/lib/plugin/screenshotOnFail.js
@@ -0,0 +1,100 @@
+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 defaultConfig = {
+  uniqueScreenshotNames: false,
+  disableScreenshots: false,
+  fullPageScreenshots: false,
+};
+
+const supportedHelpers = [
+  'WebDriverIO',
+  'Protractor',
+  'Appium',
+  'Nightmare',
+  'Puppeteer',
+];
+
+/**
+ * Creates screenshot on failure. Screenshot is saved into `output` directory.
+ *
+ * Initially this functionality was part of corresponding helper but has been moved into plugin since 1.4
+ *
+ * This plugin is **enabled by default**.
+ *
+ * ## Configuration
+ *
+ * Configuration can either be taken from a corresponding helper (deprecated) or a from plugin config (recommended).
+ *
+ * ```js
+ * "plugins": {
+ *    "screenshotOnFail": {
+ *      "enabled": true
+ *    }
+ * }
+ * ```
+ *
+ * Possible config options:
+ *
+ * * `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
+ * * `fullPageScreenshots`: make full page screenshots. Default: false.
+ *
+ *
+ */
+module.exports = function (config) {
+  const helpers = Container.helpers();
+  let helper;
+
+  for (const helperName of supportedHelpers) {
+    if (Object.keys(helpers).indexOf(helperName) > -1) {
+      helper = helpers[helperName];
+    }
+  }
+
+  if (!helper) return; // no helpers for screenshot
+
+  const options = Object.assign(defaultConfig, helper.options, config);
+
+
+  if (options.disableScreenshots) {
+    // old version of disabling screenshots
+    return;
+  }
+
+  event.dispatcher.on(event.test.failed, (test, fail) => {
+    recorder.add('screenshot of failed test', async () => {
+      let fileName = clearString(test.title);
+      if (test.ctx && test.ctx.test && test.ctx.test.type === 'hook') fileName = clearString(`${test.title}_${test.ctx.test.title}`);
+      if (options.uniqueScreenshotNames) {
+        const uuid = test.uuid || test.ctx.test.uuid || Math.floor(new Date().getTime() / 1000);
+        fileName = `${fileName.substring(0, 10)}_${uuid}.failed.png`;
+      } else {
+        fileName += '.failed.png';
+      }
+      output.plugin('screenshotOnFail', 'Test failed, saving screenshot');
+
+      try {
+        await helper.saveScreenshot(fileName, options.fullPageScreenshots);
+      } catch (err) {
+        if (err &&
+            err.type &&
+            err.type === 'RuntimeError' &&
+            err.message &&
+            (err.message.indexOf('was terminated due to') > -1 || err.message.indexOf('no such window: target window already closed') > -1)
+        ) {
+          helper.isRunning = false;
+        }
+      }
+
+      const allureReporter = Container.plugins('allure');
+      if (allureReporter) {
+        allureReporter.addAttachment('Last Seen Screenshot', fs.readFileSync(path.join(global.output_dir, fileName)), 'image/png');
+      }
+    }, true);
+  });
+};
diff --git a/lib/plugin/stepByStepReport.js b/lib/plugin/stepByStepReport.js
new file mode 100644
index 00000000..dcd1f026
--- /dev/null
+++ b/lib/plugin/stepByStepReport.js
@@ -0,0 +1,346 @@
+const Container = require('../container');
+const recorder = require('../recorder');
+const event = require('../event');
+const output = require('../output');
+const mkdirp = require('mkdirp');
+const fs = require('fs');
+const path = require('path');
+const figures = require('figures');
+const colors = require('chalk');
+const { template, clearString, deleteDir } = require('../utils');
+
+const supportedHelpers = [
+  'WebDriverIO',
+  'Protractor',
+  'Appium',
+  'Nightmare',
+  'Puppeteer',
+];
+
+const defaultConfig = {
+  deleteSuccessful: true,
+  animateSlides: true,
+  ignoreSteps: [],
+  fullPageScreenshots: false,
+};
+
+const templates = {};
+
+/**
+ * ![](https://user-images.githubusercontent.com/220264/45696671-c58cc800-bb6c-11e8-9bd9-28396805de2b.png)
+ *
+ * Generates step by step report for a test.
+ * After each step in a test a screenshot is created. After test executed screenshots are combined into slideshow.
+ * By default, reports are generated only for failed tests.
+ *
+ * ## Configuration
+ *
+ * ```js
+ * "plugins": {
+ *    "stepByStepReport": {
+ *      "enabled": true
+ *    }
+ * }
+ * ```
+ *
+ * Possible config options:
+ *
+ * * `deleteSuccessful`: do not save screenshots for successfully executed tests. Default: true.
+ * * `animateSlides`: should animation for slides to be used. Default: true.
+ * * `ignoreSteps`: steps to ignore in report. Array of RegExps is expected. Recommended to skip `grab*` and `wait*` steps.
+ * * `fullPageScreenshots`: should full page screenshots be used. Default: false.
+ *
+ * ## Allure Reports
+ *
+ * If Allure plugin is enabled this plugin attaches each saved screenshot to allure report.
+ *
+ *
+ * @param {*} config
+ */
+module.exports = function (config) {
+  const helpers = Container.helpers();
+  let helper;
+
+  config = Object.assign(defaultConfig, config);
+
+  for (const helperName of supportedHelpers) {
+    if (Object.keys(helpers).indexOf(helperName) > -1) {
+      helper = helpers[helperName];
+    }
+  }
+
+  if (!helper) return; // no helpers for screenshot
+
+  let dir;
+  let stepNum;
+  let slides = {};
+  let error;
+  let savedStep = null;
+  const uuid = Math.floor(new Date().getTime() / 1000);
+  const recordedTests = {};
+  const pad = '0000';
+
+  event.dispatcher.on(event.test.before, (test) => {
+    dir = path.join(global.output_dir, `record_${clearString(test.title).substring(0, 20)}_${uuid}`);
+    mkdirp.sync(dir);
+    stepNum = 0;
+    error = null;
+    slides = {};
+    savedStep = null;
+  });
+
+  event.dispatcher.on(event.step.after, (step) => {
+    recorder.add('screenshot of failed test', async () => persistStep(step), true);
+  });
+
+  event.dispatcher.on(event.step.failed, persistStep);
+
+
+  event.dispatcher.on(event.test.passed, (test) => {
+    if (!config.deleteSuccessful) return persist(test);
+    // cleanup
+    deleteDir(dir);
+  });
+
+  event.dispatcher.on(event.test.failed, (test, err) => {
+    persist(test, err);
+  });
+
+  event.dispatcher.on(event.all.result, () => {
+    if (!Object.keys(slides).length) return;
+
+    let links = '';
+
+    for (const link in recordedTests) {
+      links += `<li><a href="${recordedTests[link]}">${link}</a></li>\n`;
+    }
+
+    const indexHTML = template(templates.index, {
+      time: Date().toString(),
+      records: links,
+    });
+
+    fs.writeFileSync(path.join(global.output_dir, 'records.html'), indexHTML);
+
+    output.print(`${figures.circleFilled} Step-by-step preview: ${colors.white.bold(`file://${global.output_dir}/records.html`)}`);
+  });
+
+  async function persistStep(step) {
+    if (isStepIgnored(step)) return;
+    if (savedStep === step) return; // already saved
+    const fileName = `${pad.substring(0, pad.length - stepNum.toString().length) + stepNum.toString()}.png`;
+    try {
+      stepNum++;
+      slides[fileName] = step;
+      await helper.saveScreenshot(path.relative(global.output_dir, path.join(dir, fileName)), config.fullPageScreenshots);
+    } catch (err) {
+      output.plugin(`Can't save step screenshot: ${err}`);
+      error = err;
+    }
+    savedStep = step;
+
+    const allureReporter = Container.plugins('allure');
+    if (allureReporter) {
+      output.plugin('stepByStepReport', 'Adding screenshot to Allure');
+      allureReporter.addAttachment('Screenshot', fs.readFileSync(path.join(dir, fileName)), 'image/png');
+    }
+  }
+
+  function persist(test, err) {
+    if (error) return;
+
+    let indicatorHtml = '';
+    let slideHtml = '';
+
+    for (const i in slides) {
+      const step = slides[i];
+      const stepNum = parseInt(i, 10);
+      indicatorHtml += template(templates.indicator, {
+        step: stepNum,
+        isActive: stepNum ? '' : 'class="active"',
+      });
+
+      slideHtml += template(templates.slides, {
+        image: i,
+        caption: step.toString(),
+        isActive: stepNum ? '' : 'active',
+        isError: step.status === 'failed' ? 'error' : '',
+      });
+    }
+
+    const html = template(templates.global, {
+      indicators: indicatorHtml,
+      slides: slideHtml,
+      feature: test.parent && test.parent.title,
+      test: test.title,
+      carousel_class: config.animateSlides ? ' slide' : '',
+    });
+
+    const index = path.join(dir, 'index.html');
+    fs.writeFileSync(index, html);
+    recordedTests[`${test.parent.title}: ${test.title}`] = path.relative(global.output_dir, index);
+  }
+
+  function isStepIgnored(step) {
+    if (!config.ignoreSteps) return;
+    for (const pattern of config.ignoreSteps || []) {
+      if (step.name.match(pattern)) return true;
+    }
+    return false;
+  }
+};
+
+templates.slides = `
+<div class="item {{isActive}}">
+    <div class="fill">
+        <img src="{{image}}">
+    </div>
+    <div class="carousel-caption {{isError}}">
+        <h2>{{caption}}</h2>
+        <small>scroll up and down to see the full page</small>
+    </div>
+</div>
+`;
+
+templates.indicator = `
+<li data-target="#steps" data-slide-to="{{step}}" {{isActive}}></li>
+`;
+
+templates.index = `
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Step by Steps Report</title>
+
+    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" rel="stylesheet">
+</head>
+<body>
+    <!-- Navigation -->
+    <nav class="navbar navbar-default" role="navigation">
+        <div class="navbar-header">
+            <a class="navbar-brand" href="#">Step by Step Report
+            </a>
+        </div>
+    </nav>
+    <div class="container">
+        <h1>Recorded <small>@ {{time}}</small></h1>
+        <ul>
+            {{records}}
+        </ul>
+    </div>
+
+</body>
+</html>
+`;
+
+templates.global = `
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Recorder Result</title>
+
+    <!-- Bootstrap Core CSS -->
+    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" rel="stylesheet">
+
+    <style>
+        html,
+        body {
+            height: 100%;
+        }
+        .carousel,
+        .item,
+        .active {
+            height: 100%;
+        }
+        .navbar {
+            margin-bottom: 0px !important;
+        }
+        .carousel-caption {
+            background: rgba(0,0,0,0.8);
+            padding-bottom: 50px !important;
+        }
+        .carousel-caption.error {
+            background: #c0392b !important;
+        }
+
+        .carousel-inner {
+            height: 100%;
+        }
+
+        .fill {
+            width: 100%;
+            height: 100%;
+            text-align: center;
+            overflow-y: scroll;
+            background-position: top;
+            -webkit-background-size: cover;
+            -moz-background-size: cover;
+            background-size: cover;
+            -o-background-size: cover;
+        }
+    </style>
+</head>
+<body>
+    <!-- Navigation -->
+    <nav class="navbar navbar-default" role="navigation">
+        <div class="navbar-header">
+            <a class="navbar-brand" href="../records.html">
+                &laquo;
+                {{feature}}
+                <small>{{test}}</small>
+            </a>
+        </div>
+    </nav>
+    <header id="steps" class="carousel{{carousel_class}}">
+        <!-- Indicators -->
+        <ol class="carousel-indicators">
+            {{indicators}}
+        </ol>
+
+        <!-- Wrapper for Slides -->
+        <div class="carousel-inner">
+            {{slides}}
+        </div>
+
+        <!-- Controls -->
+        <a class="left carousel-control" href="#steps" data-slide="prev">
+            <span class="icon-prev"></span>
+        </a>
+        <a class="right carousel-control" href="#steps" data-slide="next">
+            <span class="icon-next"></span>
+        </a>
+
+    </header>
+
+    <!-- jQuery -->
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
+    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
+
+    <!-- Script to Activate the Carousel -->
+    <script>
+    $('.carousel').carousel({
+        wrap: true,
+        interval: false
+    })
+
+    $(document).bind('keyup', function(e) {
+      if(e.keyCode==39){
+      jQuery('a.carousel-control.right').trigger('click');
+      }
+
+      else if(e.keyCode==37){
+      jQuery('a.carousel-control.left').trigger('click');
+      }
+
+    });
+
+    </script>
+
+</body>
+
+</html>
+`;
diff --git a/lib/reporter/cli.js b/lib/reporter/cli.js
index 605fe62b..57b7dfb2 100644
--- a/lib/reporter/cli.js
+++ b/lib/reporter/cli.js
@@ -5,7 +5,7 @@ const AssertionFailedError = require('../assert/error');
 const output = require('../output');
 
 const cursor = Base.cursor;
-let currentMetaStep = null;
+let currentMetaStep = [];
 
 class Cli extends Base {
   constructor(runner, opts) {
@@ -27,6 +27,13 @@ class Cli extends Base {
       return Array(indents).join('  ');
     }
 
+    if (showSteps) {
+      const Containter = require('../container');
+      output.print();
+      output.print(output.styles.debug(`Enabled Helpers: ${Object.keys(Containter.helpers()).join(', ')}`));
+      output.print(output.styles.debug(`Enabled Plugins: ${Object.keys(Containter.plugins()).join(', ')}`));
+    }
+
     runner.on('start', () => {
       console.log();
     });
@@ -58,25 +65,30 @@ class Cli extends Base {
 
     if (showSteps) {
       runner.on('test', (test) => {
-        currentMetaStep = '';
+        currentMetaStep = [];
         if (test.steps) {
           output.test.started(test);
         }
       });
 
       event.dispatcher.on(event.step.started, (step) => {
-        if (step.metaStep) {
-          if (currentMetaStep !== step.metaStep.toString()) {
-            output.step(step.metaStep);
-            currentMetaStep = step.metaStep.toString();
+        output.stepShift = 3;
+        const printMetaStep = (metaStep) => {
+          if (!metaStep) return currentMetaStep.shift();
+          if (currentMetaStep.indexOf(metaStep.toString()) >= 0) return; // step is the same
+          if (metaStep.metaStep) {
+            printMetaStep(metaStep.metaStep);
           }
-        } else {
-          currentMetaStep = '';
-        }
+          currentMetaStep.unshift(metaStep.toString());
+          output.step(metaStep);
+        };
+        printMetaStep(step.metaStep);
         output.step(step);
       });
-      event.dispatcher.on(event.step.passed, step => output.stepExecutionTime(step));
-      event.dispatcher.on(event.step.failed, step => output.stepExecutionTime(step));
+
+      event.dispatcher.on(event.step.finished, (step) => {
+        output.stepShift = 0;
+      });
     }
 
     runner.on('end', this.result.bind(this));
@@ -99,10 +111,12 @@ class Cli extends Base {
         if (err instanceof AssertionFailedError) {
           err.message = err.cliMessage();
 
-          // remove error message from stacktrace
-          const lines = err.stack.split('\n');
-          lines.splice(0, 1);
-          err.stack = lines.join('\n');
+          if (err.stack) {
+            // remove error message from stacktrace
+            const lines = err.stack.split('\n');
+            lines.splice(0, 1);
+            err.stack = lines.join('\n');
+          }
         }
         if (output.level() < 3) {
           err.stack += '\n\nRun with --verbose flag to see NodeJS stacktrace';
diff --git a/lib/step.js b/lib/step.js
index 5d8858f1..139718fd 100644
--- a/lib/step.js
+++ b/lib/step.js
@@ -1,10 +1,9 @@
-const Config = require('./config');
-const path = require('path');
+const STACK_LINE = 4;
 
+// using support objetcs for metastep detection
+// deprecated
 let support;
 
-const STACK_LINE = 3;
-
 /**
  * Each command in test executed through `I.` object is wrapped in Step.
  * Step allows logging executed commands and triggers hook before and after step execution.
@@ -37,14 +36,19 @@ class Step {
     let result;
     try {
       result = this.helper[this.helperMethod].apply(this.helper, this.args);
-      this.status = 'success';
+      this.setStatus('success');
     } catch (err) {
-      this.status = 'failed';
+      this.setStatus('failed');
       throw err;
     }
     return result;
   }
 
+  setStatus(status) {
+    this.status = status;
+    if (this.metaStep) this.metaStep.setStatus(status);
+  }
+
   humanize() {
     return this.name
     // insert a space before all caps
@@ -81,7 +85,6 @@ class Step {
 
   line() {
     const lines = this.stack.split('\n');
-    // 3rd line is line where step has been called in test
     if (lines[STACK_LINE]) return lines[STACK_LINE].trim();
     return '';
   }
@@ -106,6 +109,10 @@ class MetaStep extends Step {
     this.actor = obj;
   }
 
+  humanize() {
+    return this.name;
+  }
+
   setTrace() {
   }
 
@@ -117,41 +124,45 @@ Step.MetaStep = MetaStep;
 
 module.exports = Step;
 
-function loadSupportObjects() {
-  support = Config.get('include', {});
-  if (support) {
-    for (const name in support) {
-      const file = support[name];
-      support[name] = path.join(global.codecept_dir, file);
-    }
-  }
-}
-
 function detectMetaStep(stack) {
-  if (!support) loadSupportObjects();
+  if (!support) loadSupportObjects(); // deprecated
 
   for (let i = STACK_LINE; i < stack.length; i++) {
     const line = stack[i].trim();
-    if (!isTest(line)) continue;
-    // console.log('aaa');
-    if (i === STACK_LINE) return;
-    for (const name in support) {
-      const file = support[name];
-      const caller = stack[i - 1].trim();
-      // const caller = stack[i-1].trim().match(/^at (\w+\.\w+)\s\(/);
-      if (caller.indexOf(file) > -1) {
-        // console.log('YEAAAH!!!!', name);
-        let fnName = caller.match(/^at \w+\.(.*?)\(/);
-        if (fnName[1]) {
-          fnName = fnName[1].trim();
+    if (isTest(line) || isBDD(line)) break;
+    const fnName = line.match(/^at (\w+)\.(\w+)\s\(/);
+    if (!fnName) continue;
+    if (fnName[1] === 'Generator') return; // don't track meta steps inside generators
+    if (fnName[1] === 'recorder') return; // don't track meta steps inside generators
+    if (fnName[1] === 'Object') {
+      // detect PO name from includes
+      for (const name in support) {
+        const file = support[name];
+        if (line.indexOf(file) > -1) {
+          return new MetaStep(`${name}:`, fnName[2]);
         }
-        return new MetaStep(name, fnName);
       }
     }
-    return;
+    return new MetaStep(`${fnName[1]}:`, fnName[2]);
+  }
+}
+
+function loadSupportObjects() {
+  const Config = require('./config');
+  const path = require('path');
+  support = Config.get('include', {});
+  if (support) {
+    for (const name in support) {
+      const file = support[name];
+      support[name] = path.join(global.codecept_dir, file);
+    }
   }
 }
 
 function isTest(line) {
   return line.trim().match(/^at Test\.Scenario/);
 }
+
+function isBDD(line) {
+  return line.trim().match(/^at (Given|When|Then)/);
+}
diff --git a/lib/utils.js b/lib/utils.js
index d585fef2..162bd965 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -253,3 +253,17 @@ module.exports.convertCssPropertiesToCamelCase = function (props) {
   });
   return output;
 };
+
+module.exports.deleteDir = function (dir_path) {
+  if (fs.existsSync(dir_path)) {
+    fs.readdirSync(dir_path).forEach(function (entry) {
+      const entry_path = path.join(dir_path, entry);
+      if (fs.lstatSync(entry_path).isDirectory()) {
+        this.deleteDir(entry_path);
+      } else {
+        fs.unlinkSync(entry_path);
+      }
+    });
+    fs.rmdirSync(dir_path);
+  }
+};
diff --git a/lib/within.js b/lib/within.js
index eabb9947..f75d328b 100644
--- a/lib/within.js
+++ b/lib/within.js
@@ -2,6 +2,7 @@ const output = require('./output');
 const recorder = require('./recorder');
 const container = require('./container');
 const event = require('./event');
+const Step = require('./step');
 const { isGenerator, isAsyncFunction } = require('./utils');
 const resumeTest = require('./scenario').resumeTest;
 
@@ -10,16 +11,18 @@ function within(context, fn) {
   const locator = typeof context === 'object' ? JSON.stringify(context) : context;
 
   return recorder.add('register within wrapper', () => {
+    const metaStep = new Step.MetaStep('Within', `"${locator}"`);
+    const defineMetaStep = step => step.metaStep = metaStep;
     recorder.session.start('within');
 
-    output.stepShift = 2;
-    if (output.level() > 0) output.print(`   Within ${output.colors.yellow.bold(locator)}:`);
+    event.dispatcher.on(event.step.before, defineMetaStep);
 
     Object.keys(helpers).forEach((helper) => {
       if (helpers[helper]._withinBegin) recorder.add(`[${helper}] start within`, () => helpers[helper]._withinBegin(context));
     });
 
     const finalize = () => {
+      event.dispatcher.removeListener(event.step.before, defineMetaStep);
       recorder.add('Finalize session within session', () => {
         output.stepShift = 1;
         recorder.session.restore('within');
diff --git a/package.json b/package.json
index eba6dd27..5ebbaf8a 100644
--- a/package.json
+++ b/package.json
@@ -36,11 +36,13 @@
     "test": "mocha test/unit --recursive && mocha test/runner --recursive"
   },
   "dependencies": {
+    "allure-js-commons": "^1.3.2",
     "chalk": "^1.1.3",
-    "commander": "^2.16.0",
+    "commander": "^2.18.0",
     "css-to-xpath": "^0.1.0",
     "cucumber-expressions": "^6.0.1",
     "escape-string-regexp": "^1.0.3",
+    "figures": "^2.0.0",
     "fn-args": "^3.0.0",
     "gherkin": "^5.1.0",
     "glob": "^6.0.1",
@@ -54,14 +56,14 @@
   },
   "devDependencies": {
     "@types/inquirer": "^0.0.35",
-    "@types/node": "^8.10.21",
+    "@types/node": "^8.10.29",
     "chai": "^3.4.1",
     "chai-as-promised": "^5.2.0",
     "co-mocha": "^1.2",
     "documentation": "^4.0.0-beta1",
     "eslint": "^4.17.0",
     "eslint-config-airbnb-base": "^12.1.0",
-    "eslint-plugin-import": "^2.13.0",
+    "eslint-plugin-import": "^2.14.0",
     "faker": "^4.1.0",
     "gulp": "^3.9.1",
     "gulp-documentation": "^2.2.0",
@@ -69,14 +71,14 @@
     "json-server": "^0.10.1",
     "nightmare": "^3.0.0",
     "nyc": "^11.9.0",
-    "protractor": "^5.3.2",
-    "puppeteer": "^1.6.0",
+    "protractor": "^5.4.1",
+    "puppeteer": "^1.8.0",
     "rosie": "^1.6.0",
     "sinon": "^1.17.2",
     "sinon-chai": "^2.14.0",
     "typescript": "^2.9.2",
     "unirest": "^0.5.1",
-    "webdriverio": "^4.13.1",
+    "webdriverio": "^4.13.2",
     "xmldom": "^0.1.27",
     "xpath": "0.0.27"
   },


Test patch:
diff --git a/test/helper/Protractor_test.js b/test/helper/Protractor_test.js
index 9100cb15..956f70d6 100644
--- a/test/helper/Protractor_test.js
+++ b/test/helper/Protractor_test.js
@@ -378,13 +378,6 @@ describe('Protractor', function () {
       yield I.saveScreenshot('protractor_user_full.png', true);
       return assert.ok(fileExists(path.join(output_dir, 'protractor_user_full.png')), null, 'file does not exists');
     });
-
-    it('should create a screenshot on fail', function* () {
-      const test = { title: 'protractor should do smth' };
-      yield I.amOnPage('/');
-      yield I._failed(test);
-      return assert.ok(fileExists(path.join(output_dir, 'protractor_should_do_smth.failed.png')), null, 'file does not exists');
-    });
   });
 
   describe('#switchToNextTab, #switchToPreviousTab, #openNewTab, #closeCurrentTab, #closeOtherTabs, #grabNumberOfOpenTabs', () => {
diff --git a/test/helper/Puppeteer_test.js b/test/helper/Puppeteer_test.js
index 2b877bed..6e78936d 100644
--- a/test/helper/Puppeteer_test.js
+++ b/test/helper/Puppeteer_test.js
@@ -260,10 +260,12 @@ describe('Puppeteer', function () {
       .then(() => I.seeNumberOfElements('#area1', 1)));
   });
 
-  describe('#switchTo', () => {
+  describe.only('#switchTo', () => {
     it('should switch reference to iframe content', () => I.amOnPage('/iframe')
       .then(() => I.switchTo('[name="content"]'))
-      .then(() => I.see('Information\nLots of valuable data here')));
+      .then(() => I.see('Information'))
+      .then(() => I.see('Lots of valuable data here'))
+    );
 
     it('should return error if iframe selector is invalid', () => I.amOnPage('/iframe')
       .then(() => I.switchTo('#invalidIframeSelector'))
@@ -281,7 +283,8 @@ describe('Puppeteer', function () {
 
     it('should return to parent frame given a null locator', () => I.amOnPage('/iframe')
       .then(() => I.switchTo('[name="content"]'))
-      .then(() => I.see('Information\nLots of valuable data here'))
+      .then(() => I.see('Information'))
+      .then(() => I.see('Lots of valuable data here'))
       .then(() => I.switchTo(null))
       .then(() => I.see('Iframe test')));
   });
diff --git a/test/helper/WebDriverIO_test.js b/test/helper/WebDriverIO_test.js
index 594f0643..ba3cc074 100644
--- a/test/helper/WebDriverIO_test.js
+++ b/test/helper/WebDriverIO_test.js
@@ -203,23 +203,6 @@ describe('WebDriverIO', function () {
       .then(() => wd.see('button was clicked')));
   });
 
-
-  describe('#saveScreenshot', () => {
-    beforeEach(() => {
-      global.output_dir = path.join(global.codecept_dir, 'output');
-    });
-
-    it('should create a screenshot on fail  @ups', () => {
-      const sec = (new Date()).getUTCMilliseconds().toString();
-      const test = {
-        title: `sw should do smth ${sec}`,
-      };
-      return wd.amOnPage('/')
-        .then(() => wd._failed(test))
-        .then(() => assert.ok(fileExists(path.join(output_dir, `sw_should_do_smth_${sec}.failed.png`)), null, 'file does not exists'));
-    });
-  });
-
   describe('#waitForValue', () => {
     it('should wait for expected value for given locator', () => wd.amOnPage('/info')
       .then(() => wd.waitForValue('//input[@name= "rus"]', 'Верно'))
diff --git a/test/helper/webapi.js b/test/helper/webapi.js
index 1f9c88d5..1364039b 100644
--- a/test/helper/webapi.js
+++ b/test/helper/webapi.js
@@ -571,16 +571,6 @@ module.exports.tests = function () {
         .then(() => I.saveScreenshot(`screenshot_full_${+sec}.png`, true))
         .then(() => assert.ok(fileExists(path.join(global.output_dir, `screenshot_full_${+sec}.png`)), null, 'file does not exists'));
     });
-
-    it('should create a screenshot on fail  @ups', () => {
-      const sec = (new Date()).getUTCMilliseconds().toString();
-      const test = {
-        title: `sw should do smth ${sec}`,
-      };
-      return I.amOnPage('/')
-        .then(() => I._failed(test))
-        .then(() => assert.ok(fileExists(path.join(global.output_dir, `sw_should_do_smth_${sec}.failed.png`)), null, 'file does not exists'));
-    });
   });
 
   describe('cookies : #setCookie, #clearCookies, #seeCookie', () => {
diff --git a/test/runner/bdd_test.js b/test/runner/bdd_test.js
index 7de24434..e946c30f 100644
--- a/test/runner/bdd_test.js
+++ b/test/runner/bdd_test.js
@@ -47,19 +47,19 @@ describe('BDD Gherkin', () => {
   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"}');
-      stdout.should.include('   Given I have product with price 10$ in my cart');
+      stdout.should.include(' Given I have product with price 10$ in my cart');
 
       stdout.should.include(' order discount {"price":"20","total":"20.0"}');
-      stdout.should.include('   Given I have product with price 20$ in my cart');
+      stdout.should.include(' Given I have product with price 20$ in my cart');
 
       stdout.should.include(' order discount {"price":"21","total":"18.9"}');
-      stdout.should.include('   Given I have product with price 21$ in my cart');
+      stdout.should.include(' Given I have product with price 21$ in my cart');
 
       stdout.should.include(' order discount {"price":"30","total":"27.0"}');
-      stdout.should.include('   Given I have product with price 30$ in my cart');
+      stdout.should.include(' Given I have product with price 30$ in my cart');
 
       stdout.should.include(' order discount {"price":"50","total":"45.0"}');
-      stdout.should.include('   Given I have product with price 50$ in my cart');
+      stdout.should.include(' Given I have product with price 50$ in my cart');
       assert(!err);
       done();
     });
diff --git a/test/runner/interface_test.js b/test/runner/interface_test.js
index 03d57ef4..a311933e 100644
--- a/test/runner/interface_test.js
+++ b/test/runner/interface_test.js
@@ -49,22 +49,22 @@ describe('CodeceptJS Interface', () => {
     exec(config_run_config('codecept.ddt.json'), (err, stdout, stderr) => {
       const output = stdout.replace(/in [0-9]ms/g, '').replace(/\r/g, '');
       output.should.include(`Got login davert and password 123456
-  ✓ Should log accounts1 | {"login":"davert","password":"123456"}`);
+  ✔ Should log accounts1 | {"login":"davert","password":"123456"}`);
 
       output.should.include(`Got login admin and password 666666
-  ✓ Should log accounts1 | {"login":"admin","password":"666666"}`);
+  ✔ Should log accounts1 | {"login":"admin","password":"666666"}`);
 
       output.should.include(`Got changed login andrey and password 555555
-  ✓ Should log accounts2 | {"login":"andrey","password":"555555"}`);
+  ✔ Should log accounts2 | {"login":"andrey","password":"555555"}`);
 
       output.should.include(`Got changed login collaborator and password 222222
-  ✓ Should log accounts2 | {"login":"collaborator","password":"222222"}`);
+  ✔ Should log accounts2 | {"login":"collaborator","password":"222222"}`);
 
       output.should.include(`Got changed login nick
-  ✓ Should log accounts3 | nick`);
+  ✔ Should log accounts3 | nick`);
 
       output.should.include(`Got changed login jack
-  ✓ Should log accounts3 | jack`);
+  ✔ Should log accounts3 | jack`);
 
       assert(!err);
       done();
@@ -107,13 +107,13 @@ describe('CodeceptJS Interface', () => {
       const lines = stdout.split('\n');
       lines.should.include.members([
         '  check current dir',
-        '   I open dir ',
-        '     I am in path "."',
-        '   I see file "codecept.json"',
-        '   MyPage has file ',
-        '     I see file "codecept.json"',
-        '     I see file "codecept.po.json"',
-        '   I see file "codecept.po.json"',
+        '    I: openDir ',
+        '      I am in path "."',
+        '      I see file "codecept.json"',
+        '    MyPage: hasFile ',
+        '      I see file "codecept.json"',
+        '      I see file "codecept.po.json"',
+        '    I see file "codecept.po.json"',
       ]);
       stdout.should.include('OK  | 1 passed');
       assert(!err);
diff --git a/test/runner/run_multiple_test.js b/test/runner/run_multiple_test.js
index 09c9f951..c4ea95db 100644
--- a/test/runner/run_multiple_test.js
+++ b/test/runner/run_multiple_test.js
@@ -45,8 +45,8 @@ describe('CodeceptJS Multiple Runner', function () {
   it('should replace parameters', (done) => {
     exec(`${codecept_run}grep --debug`, (err, stdout, stderr) => {
       stdout.should.include('CodeceptJS'); // feature
-      stdout.should.include('[1.grep:chrome]  > maximize');
-      stdout.should.include('[2.grep:firefox]  > 1200x840');
+      stdout.should.include('[1.grep:chrome]     › maximize');
+      stdout.should.include('[2.grep:firefox]     › 1200x840');
       assert(!err);
       done();
     });
@@ -81,9 +81,9 @@ describe('CodeceptJS Multiple Runner', function () {
     exec(`${codecept_run}default --steps`, (err, stdout, stderr) => {
       stdout.should.include('CodeceptJS'); // feature
       stdout.should.include('[2.default:firefox]   print browser ');
-      stdout.should.include('[2.default:firefox]    I print browser');
+      stdout.should.include('[2.default:firefox]     I print browser ');
       stdout.should.include('[1.default:chrome]   print browser ');
-      stdout.should.include('[1.default:chrome]    I print browser');
+      stdout.should.include('[1.default:chrome]     I print browser ');
       assert(!err);
       done();
     });
diff --git a/test/runner/within_test.js b/test/runner/within_test.js
index 51a25e2b..ca6e8fe0 100644
--- a/test/runner/within_test.js
+++ b/test/runner/within_test.js
@@ -16,7 +16,7 @@ describe('CodeceptJS within', function () {
   });
 
   it('should execute if no generators', (done) => {
-    exec(`${codecept_run} --steps`, (err, stdout, stderr) => {
+    exec(`${codecept_run} --debug`, (err, stdout, stderr) => {
       const lines = stdout.match(/\S.+/g);
 
       const withoutGeneratorList = grepLines(lines, 'Check within without generator', 'Check within with generator. Yield is first in order');
@@ -25,8 +25,8 @@ describe('CodeceptJS within', function () {
       withoutGeneratorList.should.eql([
         'I small promise ',
         'small Promise was finished',
-        'Within blabla:',
         'Hey! I am within Begin. I get blabla',
+        'Within "blabla" ',
         'I small promise ',
         'small Promise was finished',
         'oh! I am within end(',
@@ -36,7 +36,7 @@ describe('CodeceptJS within', function () {
   });
 
   it('should execute with generators. Yield is first in order', (done) => {
-    exec(`${codecept_run} --steps`, (err, stdout, stderr) => {
+    exec(`${codecept_run} --debug`, (err, stdout, stderr) => {
       const lines = stdout.match(/\S.+/g);
 
       const withGeneratorList = grepLines(lines, 'Check within with generator. Yield is first in order', 'Check within with generator. Yield is second in order');
@@ -47,8 +47,8 @@ describe('CodeceptJS within', function () {
         'small Promise was finished',
         'I small yield ',
         'I am small yield string',
-        'Within blabla:',
         'Hey! I am within Begin. I get blabla',
+        'Within "blabla" ',
         'I small yield ',
         'I am small yield string',
         'I small promise ',
@@ -61,7 +61,7 @@ describe('CodeceptJS within', function () {
   });
 
   it('should execute with generators. Yield is second in order', (done) => {
-    exec(`${codecept_run} --steps`, (err, stdout, stderr) => {
+    exec(`${codecept_run} --debug`, (err, stdout, stderr) => {
       const lines = stdout.match(/\S.+/g);
 
       const withGeneratorListOtherOrder = grepLines(lines, 'Check within with generator. Yield is second in order', 'Check within with generator. Should complete test steps after within');
@@ -72,8 +72,8 @@ describe('CodeceptJS within', function () {
         'small Promise was finished',
         'I small yield ',
         'I am small yield string',
-        'Within blabla:',
         'Hey! I am within Begin. I get blabla',
+        'Within "blabla" ',
         'I small promise ',
         'small Promise was finished',
         'I small yield ',
@@ -86,7 +86,7 @@ describe('CodeceptJS within', function () {
   });
 
   it('should execute with async/await. Await is first in order', (done) => {
-    exec(`${codecept_run} --steps`, (err, stdout, stderr) => {
+    exec(`${codecept_run} --debug`, (err, stdout, stderr) => {
       const lines = stdout.match(/\S.+/g);
 
       const withGeneratorList = grepLines(lines, 'Check within with async/await. Await is first in order', 'Check within with async/await. Await is second in order');
@@ -97,8 +97,8 @@ describe('CodeceptJS within', function () {
         'small Promise was finished',
         'I small yield ',
         'I am small yield string await',
-        'Within blabla:',
         'Hey! I am within Begin. I get blabla',
+        'Within "blabla" ',
         'I small yield ',
         'I am small yield string await',
         'I small promise ',
@@ -111,7 +111,7 @@ describe('CodeceptJS within', function () {
   });
 
   it('should execute with async/await. Await is second in order', (done) => {
-    exec(`${codecept_run} --steps`, (err, stdout, stderr) => {
+    exec(`${codecept_run} --debug`, (err, stdout, stderr) => {
       const lines = stdout.match(/\S.+/g);
 
       const withGeneratorList = grepLines(lines, 'Check within with async/await. Await is second in order', '-- FAILURES:');
@@ -122,8 +122,8 @@ describe('CodeceptJS within', function () {
         'small Promise was finished',
         'I small yield ',
         'I am small yield string await',
-        'Within blabla:',
         'Hey! I am within Begin. I get blabla',
+        'Within "blabla" ',
         'I small promise ',
         'small Promise was finished',
         'I small yield ',
@@ -137,7 +137,7 @@ describe('CodeceptJS within', function () {
 
 
   it('should complete test steps after within', (done) => {
-    exec(`${codecept_run} --steps`, (err, stdout, stderr) => {
+    exec(`${codecept_run} --debug`, (err, stdout, stderr) => {
       const lines = stdout.match(/\S.+/g);
 
       const withGeneratorListWithContinued = grepLines(lines, 'Check within with generator. Should complete test steps after within', 'Check within with generator. Should stop test execution after fail in within');
@@ -146,8 +146,8 @@ describe('CodeceptJS within', function () {
       withGeneratorListWithContinued.should.eql([
         'I small yield ',
         'I am small yield string',
-        'Within blabla:',
         'Hey! I am within Begin. I get blabla',
+        'Within "blabla" ',
         'I small yield ',
         'I am small yield string',
         'I small promise ',
@@ -161,7 +161,7 @@ describe('CodeceptJS within', function () {
   });
 
   it('should stop test execution after fail in within', (done) => {
-    exec(`${codecept_run} --steps`, (err, stdout, stderr) => {
+    exec(`${codecept_run} --debug`, (err, stdout, stderr) => {
       const lines = stdout.match(/\S.+/g);
 
       const errorInWithinList = grepLines(lines, 'Check within with generator. Should stop test execution after fail in within', 'Check within with generator. Should stop test execution after fail in main block');
@@ -170,8 +170,8 @@ describe('CodeceptJS within', function () {
       errorInWithinList.should.eql([
         'I small yield ',
         'I am small yield string',
-        'Within blabla:',
         'Hey! I am within Begin. I get blabla',
+        'Within "blabla" ',
         'I error step ',
         'oh! I am within end(',
       ], 'check steps execution order');
@@ -181,7 +181,7 @@ describe('CodeceptJS within', function () {
   });
 
   it('should stop test execution after fail in main block', (done) => {
-    exec(`${codecept_run} --steps`, (err, stdout, stderr) => {
+    exec(`${codecept_run} --debug`, (err, stdout, stderr) => {
       const lines = stdout.match(/\S.+/g);
 
       const errorInTestList = grepLines(lines, 'Check within with generator. Should stop test execution after fail in main block', 'Check within with async/await. Await is first in order');
diff --git a/test/unit/container_test.js b/test/unit/container_test.js
index c4a68455..ed2a3013 100644
--- a/test/unit/container_test.js
+++ b/test/unit/container_test.js
@@ -70,6 +70,25 @@ describe('Container', () => {
     });
   });
 
+  describe('#plugins', () => {
+    beforeEach(() => {
+      container.clear({}, {}, {
+        plugin1: { name: 'hello' },
+        plugin2: { name: 'world' },
+      });
+    });
+
+    it('should return all plugins', () => container.plugins().should.have.keys('plugin1', 'plugin2'));
+
+    it('should get plugin by name', () => {
+      container.plugins('plugin1').should.be.ok;
+      container.plugins('plugin1').name.should.eql('hello');
+      container.plugins('plugin2').should.be.ok;
+      container.plugins('plugin2').name.should.eql('world');
+      assert.ok(!container.plugins('plugin3'));
+    });
+  });
+
   describe('#create', () => {
     it('should create container with helpers', () => {
       const config = {
diff --git a/test/unit/plugin/screenshotOnFail_test.js b/test/unit/plugin/screenshotOnFail_test.js
new file mode 100644
index 00000000..83303895
--- /dev/null
+++ b/test/unit/plugin/screenshotOnFail_test.js
@@ -0,0 +1,39 @@
+const screenshotOnFail = require('../../../lib/plugin/screenshotOnFail');
+const sinon = require('sinon');
+const container = require('../../../lib/container');
+const event = require('../../../lib/event');
+const recorder = require('../../../lib/recorder');
+const assert = require('assert');
+
+let screenshotSaved;
+
+describe('screenshotOnFail', () => {
+  beforeEach(() => {
+    recorder.reset();
+    screenshotSaved = sinon.spy();
+    container.clear({
+      WebDriverIO: {
+        options: {},
+        saveScreenshot: screenshotSaved,
+      },
+    });
+  });
+
+  it('should create screenshot on fail', async () => {
+    screenshotOnFail({});
+    event.dispatcher.emit(event.test.failed, { title: 'test1' });
+    await recorder.promise();
+    assert.ok(screenshotSaved.called);
+    assert.equal('test1.failed.png', screenshotSaved.getCall(0).args[0]);
+  });
+
+  it('should create screenshot with unique name', async () => {
+    screenshotOnFail({ uniqueScreenshotNames: true });
+    event.dispatcher.emit(event.test.failed, { title: 'test1', uuid: 1 });
+    await recorder.promise();
+    assert.ok(screenshotSaved.called);
+    assert.equal('test1_1.failed.png', screenshotSaved.getCall(0).args[0]);
+  });
+
+  // TODO: write more tests for different options
+});
diff --git a/test/unit/scenario_test.js b/test/unit/scenario_test.js
index 8f6462d4..f6335dba 100644
--- a/test/unit/scenario_test.js
+++ b/test/unit/scenario_test.js
@@ -23,7 +23,7 @@ describe('Scenario', () => {
   afterEach(() => event.cleanDispatcher());
 
   it('should wrap test function', () => {
-    scenario.test(test).fn();
+    scenario.test(test).fn(() => {});
     assert.ok(fn.called);
   });
 
@@ -88,7 +88,7 @@ describe('Scenario', () => {
       test.fn = () => {
         throw new Error('ups');
       };
-      scenario.test(test).fn();
+      scenario.test(test).fn(() => {});
       return recorder.promise()
         .then(() => assert.ok(failed.called))
         .catch(() => null);
@@ -98,7 +98,7 @@ describe('Scenario', () => {
       test.fn = () => {
         recorder.throw(new Error('ups'));
       };
-      scenario.test(test).fn();
+      scenario.test(test).fn(() => {});
       return recorder.promise()
         .then(() => assert.ok(failed.called))
         .catch(() => null);