From 8278d6aece842131027de81179f9824f4600c78e Mon Sep 17 00:00:00 2001
From: CJ Cenizal <cj@cenizal.com>
Date: Tue, 21 Jun 2016 16:59:43 -0700
Subject: [PATCH] Create visual_regression test suite. - Add
 test:visualRegression npm script. - Split up test:visualRegression grunt task
 into takeScreenshots and buildGallery subtasks. - Add
 intern_visual_regression.js test file. - Rename api_itern.js -> intern_api.js
 for consistency. - Refactor delayed exports logic for readability. - Refactor
 common.js page object to use ES2015 Class.

Former-commit-id: 37f82f146f297db36f9c7cba1c5729403c73389c
---
 package.json                            |   1 +
 tasks/config/intern.js                  |   8 +-
 tasks/test.js                           |  15 +-
 test/functional/index.js                |  12 +-
 test/intern.js                          |   6 +-
 test/{api_intern.js => intern_api.js}   |   0
 test/intern_visual_regression.js        |  24 ++
 test/support/index.js                   |  62 ++-
 test/support/pages/common.js            | 520 ++++++++++++------------
 test/visual_regression/home/_loading.js |  14 +
 test/visual_regression/home/index.js    |  16 +
 test/visual_regression/index.js         |  26 ++
 12 files changed, 415 insertions(+), 289 deletions(-)
 rename test/{api_intern.js => intern_api.js} (100%)
 create mode 100644 test/intern_visual_regression.js
 create mode 100644 test/visual_regression/home/_loading.js
 create mode 100644 test/visual_regression/home/index.js
 create mode 100644 test/visual_regression/index.js

diff --git a/package.json b/package.json
index 5c5d328a3030b..455334a24d4c3 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
     "test:ui:runner": "grunt test:ui:runner",
     "test:server": "grunt test:server",
     "test:coverage": "grunt test:coverage",
+    "test:visualRegression": "grunt test:visualRegression",
     "build": "grunt build",
     "release": "grunt release",
     "start": "sh ./bin/kibana --dev",
diff --git a/tasks/config/intern.js b/tasks/config/intern.js
index 3a56007072dfc..7fc86fbdef2cc 100644
--- a/tasks/config/intern.js
+++ b/tasks/config/intern.js
@@ -13,7 +13,13 @@ module.exports = function (grunt) {
     api: {
       options: {
         runType: 'client',
-        config: 'test/api_intern'
+        config: 'test/intern_api'
+      }
+    },
+    visualRegression: {
+      options: {
+        runType: 'runner',
+        config: 'test/intern_visual_regression'
       }
     }
   };
diff --git a/tasks/test.js b/tasks/test.js
index dd56aff10547c..07ab10d225ef0 100644
--- a/tasks/test.js
+++ b/tasks/test.js
@@ -2,8 +2,18 @@ const _ = require('lodash');
 const visualRegression = require('../utilities/visual_regression');
 
 module.exports = function (grunt) {
+  grunt.registerTask('test:visualRegression', [
+    'intern:visualRegression:takeScreenshots',
+    'test:visualRegression:buildGallery'
+  ]);
+
+  grunt.registerTask('test:visualRegression:takeScreenshots', [
+    'clean:screenshots',
+    'intern:visualRegression'
+  ]);
+
   grunt.registerTask(
-    'test:visualRegression',
+    'test:visualRegression:buildGallery',
     'Compare screenshots and generate diff images.',
     function () {
       const done = this.async();
@@ -72,8 +82,7 @@ module.exports = function (grunt) {
     grunt.task.run(_.compact([
       !grunt.option('quick') && 'eslint:source',
       'licenses',
-      'test:quick',
-      'test:visualRegression'
+      'test:quick'
     ]));
   });
 
diff --git a/test/functional/index.js b/test/functional/index.js
index c881e78b1abe7..2a1848dfed74d 100644
--- a/test/functional/index.js
+++ b/test/functional/index.js
@@ -3,18 +3,18 @@ define(function (require) {
 
   const bdd = require('intern!bdd');
   const intern = require('intern');
-  const earliestBeforeCbs = [];
+  const initCallbacks = [];
 
-  function onEarliestBefore(cb) {
-    earliestBeforeCbs.push(cb);
+  function onInit(callback) {
+    initCallbacks.push(callback);
   }
 
-  global.__kibana__intern__ = { intern, bdd, onEarliestBefore };
+  global.__kibana__intern__ = { intern, bdd, onInit };
 
   bdd.describe('kibana', function () {
     bdd.before(function () {
-      earliestBeforeCbs.forEach(cb => {
-        cb.call(this);
+      initCallbacks.forEach(callback => {
+        callback.call(this);
       });
     });
 
diff --git a/test/intern.js b/test/intern.js
index 29599e6997913..f3ee839d7a0d1 100644
--- a/test/intern.js
+++ b/test/intern.js
@@ -1,8 +1,6 @@
 define(function (require) {
-  var serverConfig = require('intern/dojo/node!./server_config');
-  var _ = require('intern/dojo/node!lodash');
-
-  return _.assign({
+  const serverConfig = require('intern/dojo/node!./server_config');
+  return Object.assign({
     debug: true,
     capabilities: {
       'selenium-version': '2.53.0',
diff --git a/test/api_intern.js b/test/intern_api.js
similarity index 100%
rename from test/api_intern.js
rename to test/intern_api.js
diff --git a/test/intern_visual_regression.js b/test/intern_visual_regression.js
new file mode 100644
index 0000000000000..778e17f0cbf8b
--- /dev/null
+++ b/test/intern_visual_regression.js
@@ -0,0 +1,24 @@
+define(function (require) {
+  const serverConfig = require('intern/dojo/node!./server_config');
+  return Object.assign({
+    debug: true,
+    capabilities: {
+      'selenium-version': '2.53.0',
+      // must match URL in tasks/config/downloadSelenium.js
+      'idle-timeout': 99
+    },
+    environments: [{
+      browserName: 'chrome'
+    }],
+    tunnelOptions: serverConfig.servers.webdriver,
+    functionalSuites: [
+      'test/visual_regression/index'
+    ],
+
+    excludeInstrumentation: /.*/,
+
+    defaultTimeout: 90000,
+    defaultTryTimeout: 40000, // tryForTime could include multiple 'find timeouts'
+    defaultFindTimeout: 10000  // this is how long we try to find elements on page
+  }, serverConfig);
+});
diff --git a/test/support/index.js b/test/support/index.js
index 6a0ccf41d014d..a4a1db5eaed5e 100644
--- a/test/support/index.js
+++ b/test/support/index.js
@@ -23,36 +23,64 @@ exports.scenarioManager = new ScenarioManager(url.format(exports.config.servers.
 exports.esClient = new EsClient(url.format(exports.config.servers.elasticsearch));
 exports.bdd = new BddWrapper(kbnInternVars.bdd);
 
-defineDelayedExport('remote', (suite) => suite.remote);
-defineDelayedExport('common', () => new Common());
-defineDelayedExport('discoverPage', () => new DiscoverPage());
-defineDelayedExport('headerPage', () => new HeaderPage());
-defineDelayedExport('settingsPage', () => new SettingsPage());
-defineDelayedExport('visualizePage', () => new VisualizePage());
-defineDelayedExport('dashboardPage', () => new DashboardPage());
-defineDelayedExport('shieldPage', () => new ShieldPage());
-defineDelayedExport('consolePage', () => new ConsolePage());
-defineDelayedExport('elasticDump', () => new ElasticDump());
+const delayedExports = [{
+  name: 'remote',
+  exportProvider: (suite) => suite.remote
+}, {
+  name: 'common',
+  exportProvider: () => new Common()
+}, {
+  name: 'discoverPage',
+  exportProvider: () => new DiscoverPage()
+}, {
+  name: 'headerPage',
+  exportProvider: () => new HeaderPage()
+}, {
+  name: 'settingsPage',
+  exportProvider: () => new SettingsPage()
+}, {
+  name: 'visualizePage',
+  exportProvider: () => new VisualizePage()
+}, {
+  name: 'dashboardPage',
+  exportProvider: () => new DashboardPage()
+}, {
+  name: 'shieldPage',
+  exportProvider: () => new ShieldPage()
+}, {
+  name: 'consolePage',
+  exportProvider: () => new ConsolePage()
+}, {
+  name: 'elasticDump',
+  exportProvider: () => new ElasticDump()
+}];
 
-// creates an export for values that aren't actually avaialable until
-// until tests start to run. These getters will throw errors if the export
+delayedExports.forEach(exportConfig => {
+  delayExportUntilInit(
+    exportConfig.name,
+    exportConfig.exportProvider
+  );
+});
+
+// Creates an export for values that aren't actually avaialable until
+// tests start to run. These getters will throw errors if the export
 // is accessed before it's available, hopefully making debugging easier.
-// Once the first before() handler is called the onEarliestBefore() handlers
+// Once the first before() handler is called the onInit() call
 // will fire and rewrite all of these exports to be their correct value.
-function defineDelayedExport(name, define) {
+function delayExportUntilInit(name, provideExport) {
   Object.defineProperty(exports, name, {
     configurable: true,
     get() {
       throw new TypeError(
-        'remote is not available until tests start to run. Move your ' +
+        'Remote is not available until tests start to run. Move your ' +
         'usage of the import inside a test setup hook or a test itself.'
       );
     }
   });
 
-  kbnInternVars.onEarliestBefore(function () {
+  kbnInternVars.onInit(function defineExport() {
     Object.defineProperty(exports, name, {
-      value: define(this),
+      value: provideExport(this),
     });
   });
 }
diff --git a/test/support/pages/common.js b/test/support/pages/common.js
index 05fd74a61ed16..215333b22c506 100644
--- a/test/support/pages/common.js
+++ b/test/support/pages/common.js
@@ -1,48 +1,58 @@
-import { config, defaultTryTimeout, defaultFindTimeout, remote, shieldPage } from '../';
+import bluebird, {
+  promisify
+} from 'bluebird';
 import fs from 'fs';
+import _ from 'lodash';
 import mkdirp from 'mkdirp';
-import { promisify } from 'bluebird';
+import moment from 'moment';
+import path from 'path';
+import testSubjSelector from '@spalger/test-subj-selector';
+import {
+  format,
+  parse
+} from 'url';
+import util from 'util';
+
+import getUrl from '../../utils/get_url';
+import {
+  config,
+  defaultTryTimeout,
+  defaultFindTimeout,
+  remote,
+  shieldPage
+} from '../index';
 
 const mkdirpAsync = promisify(mkdirp);
 const writeFileAsync = promisify(fs.writeFile);
 
-export default (function () {
-  var Promise = require('bluebird');
-  var moment = require('moment');
-  var testSubjSelector = require('@spalger/test-subj-selector');
-  var getUrl = require('../../utils/get_url');
-  var _ = require('lodash');
-  var parse = require('url').parse;
-  var format = require('url').format;
-  var util = require('util');
-  var path = require('path');
-
-  function injectTimestampQuery(func, url) {
-    var formatted = modifyQueryString(url, function (parsed) {
-      parsed.query._t = Date.now();
-    });
-    return func.call(this, formatted);
-  }
+export default class Common {
 
-  function removeTimestampQuery(func) {
-    return func.call(this)
-    .then(function (url) {
-      return modifyQueryString(url, function (parsed) {
-        parsed.query = _.omit(parsed.query, '_t');
+  constructor() {
+    function injectTimestampQuery(func, url) {
+      var formatted = modifyQueryString(url, function (parsed) {
+        parsed.query._t = Date.now();
       });
-    });
-  }
+      return func.call(this, formatted);
+    }
 
-  function modifyQueryString(url, func) {
-    var parsed = parse(url, true);
-    if (parsed.query === null) {
-      parsed.query = {};
+    function removeTimestampQuery(func) {
+      return func.call(this)
+      .then(function (url) {
+        return modifyQueryString(url, function (parsed) {
+          parsed.query = _.omit(parsed.query, '_t');
+        });
+      });
+    }
+
+    function modifyQueryString(url, func) {
+      var parsed = parse(url, true);
+      if (parsed.query === null) {
+        parsed.query = {};
+      }
+      func(parsed);
+      return format(_.pick(parsed, 'protocol', 'hostname', 'port', 'pathname', 'query', 'hash', 'auth'));
     }
-    func(parsed);
-    return format(_.pick(parsed, 'protocol', 'hostname', 'port', 'pathname', 'query', 'hash', 'auth'));
-  }
 
-  function Common() {
     this.remote = remote;
     if (remote.get.wrapper !== injectTimestampQuery) {
       this.remote.get = _.wrap(this.remote.get, injectTimestampQuery);
@@ -51,253 +61,247 @@ export default (function () {
     }
   }
 
-  Common.prototype = {
-    constructor: Common,
-
-    getHostPort: function getHostPort() {
-      return getUrl.baseUrl(config.servers.kibana);
-    },
-
-    getEsHostPort: function getHostPort() {
-      return getUrl.baseUrl(config.servers.elasticsearch);
-    },
-
-    navigateToApp: function (appName, testStatusPage) {
-      var self = this;
-      var appUrl = getUrl.noAuth(config.servers.kibana, config.apps[appName]);
-      self.debug('navigating to ' + appName + ' url: ' + appUrl);
-
-      var doNavigation = function (url) {
-        return self.try(function () {
-          // since we're using hash URLs, always reload first to force re-render
-          self.debug('navigate to: ' + url);
-          return self.remote.get(url)
-          .then(function () {
-            return self.sleep(700);
-          })
-          .then(function () {
-            self.debug('returned from get, calling refresh');
-            return self.remote.refresh();
-          })
-          .then(function () {
-            self.debug('check testStatusPage');
-            if (testStatusPage !== false) {
-              self.debug('self.checkForKibanaApp()');
-              return self.checkForKibanaApp()
-              .then(function (kibanaLoaded) {
-                self.debug('kibanaLoaded = ' + kibanaLoaded);
-                if (!kibanaLoaded) {
-                  var msg = 'Kibana is not loaded, retrying';
-                  self.debug(msg);
-                  throw new Error(msg);
-                }
-              });
-            }
-          })
-          .then(function () {
-            return self.remote.getCurrentUrl();
-          })
-          .then(function (currentUrl) {
-            var loginPage = new RegExp('login').test(currentUrl);
-            if (loginPage) {
-              self.debug('Found loginPage = ' + loginPage + ', username = '
-                + config.servers.kibana.shield.username);
-              return shieldPage.login(config.servers.kibana.shield.username,
-                config.servers.kibana.shield.password)
-              .then(function () {
-                return self.remote.getCurrentUrl();
-              });
-            } else {
-              return currentUrl;
-            }
-          })
-          .then(function (currentUrl) {
-            currentUrl = currentUrl.replace(/\/\/\w+:\w+@/, '//');
-            var maxAdditionalLengthOnNavUrl = 230;
-            // On several test failures at the end of the TileMap test we try to navigate back to
-            // Visualize so we can create the next Vertical Bar Chart, but we can see from the
-            // logging and the screenshot that it's still on the TileMap page. Why didn't the "get"
-            // with a new timestamped URL go? I thought that sleep(700) between the get and the
-            // refresh would solve the problem but didn't seem to always work.
-            // So this hack fails the navSuccessful check if the currentUrl doesn't match the
-            // appUrl plus up to 230 other chars.
-            // Navigating to Settings when there is a default index pattern has a URL length of 196
-            // (from debug output). Some other tabs may also be long. But a rather simple configured
-            // visualization is about 1000 chars long. So at least we catch that case.
-            var navSuccessful = new RegExp(appUrl + '.{0,' + maxAdditionalLengthOnNavUrl + '}$')
-            .test(currentUrl);
-
-            if (!navSuccessful) {
-              var msg = 'App failed to load: ' + appName +
-              ' in ' + defaultFindTimeout + 'ms' +
-              ' appUrl = ' + appUrl +
-              ' currentUrl = ' + currentUrl;
-              self.debug(msg);
-              throw new Error(msg);
-            }
+  getHostPort() {
+    return getUrl.baseUrl(config.servers.kibana);
+  }
+
+  getEsHostPort() {
+    return getUrl.baseUrl(config.servers.elasticsearch);
+  }
+
+  navigateToApp(appName, testStatusPage) {
+    var self = this;
+    var appUrl = getUrl.noAuth(config.servers.kibana, config.apps[appName]);
+    self.debug('navigating to ' + appName + ' url: ' + appUrl);
 
+    function navigateTo(url) {
+      return self.try(function () {
+        // since we're using hash URLs, always reload first to force re-render
+        self.debug('navigate to: ' + url);
+        return self.remote.get(url)
+        .then(function () {
+          return self.sleep(700);
+        })
+        .then(function () {
+          self.debug('returned from get, calling refresh');
+          return self.remote.refresh();
+        })
+        .then(function () {
+          self.debug('check testStatusPage');
+          if (testStatusPage !== false) {
+            self.debug('self.checkForKibanaApp()');
+            return self.checkForKibanaApp()
+            .then(function (kibanaLoaded) {
+              self.debug('kibanaLoaded = ' + kibanaLoaded);
+              if (!kibanaLoaded) {
+                var msg = 'Kibana is not loaded, retrying';
+                self.debug(msg);
+                throw new Error(msg);
+              }
+            });
+          }
+        })
+        .then(function () {
+          return self.remote.getCurrentUrl();
+        })
+        .then(function (currentUrl) {
+          var loginPage = new RegExp('login').test(currentUrl);
+          if (loginPage) {
+            self.debug('Found loginPage = ' + loginPage + ', username = '
+              + config.servers.kibana.shield.username);
+            return shieldPage.login(config.servers.kibana.shield.username,
+              config.servers.kibana.shield.password)
+            .then(function () {
+              return self.remote.getCurrentUrl();
+            });
+          } else {
             return currentUrl;
-          });
-        });
-      };
-
-      return doNavigation(appUrl)
-      .then(function (currentUrl) {
-        var lastUrl = currentUrl;
-        return self.try(function () {
-          // give the app time to update the URL
-          return self.sleep(501)
-          .then(function () {
-            return self.remote.getCurrentUrl();
-          })
-          .then(function (currentUrl) {
-            self.debug('in doNavigation url = ' + currentUrl);
-            if (lastUrl !== currentUrl) {
-              lastUrl = currentUrl;
-              throw new Error('URL changed, waiting for it to settle');
-            }
-          });
+          }
+        })
+        .then(function (currentUrl) {
+          currentUrl = currentUrl.replace(/\/\/\w+:\w+@/, '//');
+          var maxAdditionalLengthOnNavUrl = 230;
+          // On several test failures at the end of the TileMap test we try to navigate back to
+          // Visualize so we can create the next Vertical Bar Chart, but we can see from the
+          // logging and the screenshot that it's still on the TileMap page. Why didn't the "get"
+          // with a new timestamped URL go? I thought that sleep(700) between the get and the
+          // refresh would solve the problem but didn't seem to always work.
+          // So this hack fails the navSuccessful check if the currentUrl doesn't match the
+          // appUrl plus up to 230 other chars.
+          // Navigating to Settings when there is a default index pattern has a URL length of 196
+          // (from debug output). Some other tabs may also be long. But a rather simple configured
+          // visualization is about 1000 chars long. So at least we catch that case.
+          var navSuccessful = new RegExp(appUrl + '.{0,' + maxAdditionalLengthOnNavUrl + '}$')
+          .test(currentUrl);
+
+          if (!navSuccessful) {
+            var msg = 'App failed to load: ' + appName +
+            ' in ' + defaultFindTimeout + 'ms' +
+            ' appUrl = ' + appUrl +
+            ' currentUrl = ' + currentUrl;
+            self.debug(msg);
+            throw new Error(msg);
+          }
+
+          return currentUrl;
         });
       });
-    },
-
-    runScript: function (fn, timeout) {
-      var self = this;
-      // by default, give the app 10 seconds to load
-      timeout = timeout || 10000;
-
-      // wait for deps on window before running script
-      return self.remote
-      .setExecuteAsyncTimeout(timeout)
-      .executeAsync(function (done) {
-        var interval = setInterval(function () {
-          var ready = (document.readyState === 'complete');
-          var hasJQuery = !!window.$;
-
-          if (ready && hasJQuery) {
-            console.log('doc ready, jquery loaded');
-            clearInterval(interval);
-            done();
+    };
+
+    return navigateTo(appUrl)
+    .then(function (currentUrl) {
+      var lastUrl = currentUrl;
+      return self.try(function () {
+        // give the app time to update the URL
+        return self.sleep(501)
+        .then(function () {
+          return self.remote.getCurrentUrl();
+        })
+        .then(function (currentUrl) {
+          self.debug('in navigateTo url = ' + currentUrl);
+          if (lastUrl !== currentUrl) {
+            lastUrl = currentUrl;
+            throw new Error('URL changed, waiting for it to settle');
           }
-        }, 10);
-      }).then(function () {
-        return self.remote.execute(fn);
+        });
       });
-    },
+    });
+  }
 
-    getApp: function () {
-      var self = this;
+  runScript(fn, timeout) {
+    var self = this;
+    // by default, give the app 10 seconds to load
+    timeout = timeout || 10000;
+
+    // wait for deps on window before running script
+    return self.remote
+    .setExecuteAsyncTimeout(timeout)
+    .executeAsync(function (done) {
+      var interval = setInterval(function () {
+        var ready = (document.readyState === 'complete');
+        var hasJQuery = !!window.$;
+
+        if (ready && hasJQuery) {
+          console.log('doc ready, jquery loaded');
+          clearInterval(interval);
+          done();
+        }
+      }, 10);
+    }).then(function () {
+      return self.remote.execute(fn);
+    });
+  }
 
-      return self.remote.setFindTimeout(defaultFindTimeout)
-      .findByCssSelector('.app-wrapper .application')
-      .then(function () {
-        return self.runScript(function () {
-          var $ = window.$;
-          var $scope = $('.app-wrapper .application').scope();
-          return $scope ? $scope.chrome.getApp() : {};
-        });
-      });
-    },
-
-    checkForKibanaApp: function () {
-      var self = this;
-
-      return self.getApp()
-      .then(function (app) {
-        var appId = app.id;
-        self.debug('current application: ' + appId);
-        return appId === 'kibana';
-      })
-      .catch(function (err) {
-        self.debug('kibana check failed');
-        self.debug(err);
-        // not on the kibana app...
-        return false;
+  getApp() {
+    var self = this;
+
+    return self.remote.setFindTimeout(defaultFindTimeout)
+    .findByCssSelector('.app-wrapper .application')
+    .then(function () {
+      return self.runScript(function () {
+        var $ = window.$;
+        var $scope = $('.app-wrapper .application').scope();
+        return $scope ? $scope.chrome.getApp() : {};
       });
-    },
+    });
+  }
 
-    tryForTime: function (timeout, block) {
-      var self = this;
-      var start = Date.now();
-      var retryDelay = 502;
-      var lastTry = 0;
-      var tempMessage;
+  checkForKibanaApp() {
+    var self = this;
+
+    return self.getApp()
+    .then(function (app) {
+      var appId = app.id;
+      self.debug('current application: ' + appId);
+      return appId === 'kibana';
+    })
+    .catch(function (err) {
+      self.debug('kibana check failed');
+      self.debug(err);
+      // not on the kibana app...
+      return false;
+    });
+  }
 
-      function attempt() {
-        lastTry = Date.now();
+  tryForTime(timeout, block) {
+    var self = this;
+    var start = Date.now();
+    var retryDelay = 502;
+    var lastTry = 0;
+    var tempMessage;
 
-        if (lastTry - start > timeout) {
-          throw new Error('timeout ' + tempMessage);
-        }
+    function attempt() {
+      lastTry = Date.now();
 
-        return Promise
-        .try(block)
-        .catch(function tryForTimeCatch(err) {
-          self.debug('tryForTime failure: ' + err.message);
-          tempMessage = err.message;
-          return Promise.delay(retryDelay).then(attempt);
-        });
+      if (lastTry - start > timeout) {
+        throw new Error('timeout ' + tempMessage);
       }
 
-      return Promise.try(attempt);
-    },
+      return bluebird
+      .try(block)
+      .catch(function tryForTimeCatch(err) {
+        self.debug('tryForTime failure: ' + err.message);
+        tempMessage = err.message;
+        return bluebird.delay(retryDelay).then(attempt);
+      });
+    }
+
+    return bluebird.try(attempt);
+  }
 
-    try(block) {
-      return this.tryForTime(defaultTryTimeout, block);
-    },
+  try(block) {
+    return this.tryForTime(defaultTryTimeout, block);
+  }
 
-    log(...args) {
-      console.log(moment().format('HH:mm:ss.SSS') + ':', util.format(...args));
-    },
+  log(...args) {
+    console.log(moment().format('HH:mm:ss.SSS') + ':', util.format(...args));
+  }
 
-    debug(...args) {
-      if (config.debug) this.log(...args);
-    },
+  debug(...args) {
+    if (config.debug) this.log(...args);
+  }
 
-    sleep: function sleep(sleepMilliseconds) {
-      var self = this;
-      self.debug('... sleep(' + sleepMilliseconds + ') start');
+  sleep(sleepMilliseconds) {
+    var self = this;
+    self.debug('... sleep(' + sleepMilliseconds + ') start');
 
-      return Promise.resolve().delay(sleepMilliseconds)
-      .then(function () { self.debug('... sleep(' + sleepMilliseconds + ') end'); });
-    },
+    return bluebird.resolve().delay(sleepMilliseconds)
+    .then(function () { self.debug('... sleep(' + sleepMilliseconds + ') end'); });
+  }
 
-    handleError(testObj) {
-      const testName = (testObj.parent) ? [testObj.parent.name, testObj.name].join('_') : testObj.name;
-      return reason => {
-        const now = Date.now();
-        const fileName = `failure_${now}_${testName}`;
+  handleError(testObj) {
+    const testName = (testObj.parent) ? [testObj.parent.name, testObj.name].join('_') : testObj.name;
+    return reason => {
+      const now = Date.now();
+      const fileName = `failure_${now}_${testName}`;
 
-        return this.saveScreenshot(fileName, true)
-        .then(function () {
-          throw reason;
-        });
-      };
-    },
-
-    async saveScreenshot(fileName, isFailure = false) {
-      try {
-        const directoryName = isFailure ? 'failure' : 'session';
-        const directoryPath = path.resolve(`test/screenshots/${directoryName}`);
-        const filePath = path.resolve(directoryPath, `${fileName}.png`);
-        this.debug(`Taking screenshot "${filePath}"`);
-
-        const screenshot = await this.remote.takeScreenshot();
-        await mkdirpAsync(directoryPath);
-        await writeFileAsync(filePath, screenshot);
-      } catch (err) {
-        this.log(`SCREENSHOT FAILED: ${err}`);
-      }
-    },
+      return this.saveScreenshot(fileName, true)
+      .then(function () {
+        throw reason;
+      });
+    };
+  }
 
-    findTestSubject: function findTestSubject(selector) {
-      this.debug('in findTestSubject: ' + testSubjSelector(selector));
-      return this.remote
-        .setFindTimeout(defaultFindTimeout)
-        .findDisplayedByCssSelector(testSubjSelector(selector));
+  async saveScreenshot(fileName, isFailure = false) {
+    try {
+      const directoryName = isFailure ? 'failure' : 'session';
+      const directoryPath = path.resolve(`test/screenshots/${directoryName}`);
+      const filePath = path.resolve(directoryPath, `${fileName}.png`);
+      this.debug(`Taking screenshot "${filePath}"`);
+
+      const screenshot = await this.remote.takeScreenshot();
+      await mkdirpAsync(directoryPath);
+      await writeFileAsync(filePath, screenshot);
+    } catch (err) {
+      this.log(`SCREENSHOT FAILED: ${err}`);
     }
+  }
 
-  };
+  findTestSubject(selector) {
+    this.debug('in findTestSubject: ' + testSubjSelector(selector));
+    return this.remote
+      .setFindTimeout(defaultFindTimeout)
+      .findDisplayedByCssSelector(testSubjSelector(selector));
+  }
 
-  return Common;
-}());
+};
diff --git a/test/visual_regression/home/_loading.js b/test/visual_regression/home/_loading.js
new file mode 100644
index 0000000000000..a3d77860f34f8
--- /dev/null
+++ b/test/visual_regression/home/_loading.js
@@ -0,0 +1,14 @@
+import {
+  bdd,
+  scenarioManager,
+  common,
+  consolePage
+} from '../../support';
+
+var expect = require('expect.js');
+
+bdd.describe('Loading', function coverLoadingUi() {
+  bdd.it('should show loading feebdack', async function () {
+    // TODO: Take screenshots here.
+  });
+});
diff --git a/test/visual_regression/home/index.js b/test/visual_regression/home/index.js
new file mode 100644
index 0000000000000..34f3aa4b3e0e0
--- /dev/null
+++ b/test/visual_regression/home/index.js
@@ -0,0 +1,16 @@
+import {
+  bdd,
+  remote,
+  scenarioManager,
+  defaultTimeout
+} from '../../support';
+
+bdd.describe('Home', function () {
+  this.timeout = defaultTimeout;
+
+  bdd.before(function () {
+    return remote.setWindowSize(1200, 800);
+  });
+
+  require('./_loading');
+});
diff --git a/test/visual_regression/index.js b/test/visual_regression/index.js
new file mode 100644
index 0000000000000..17bec9761f36e
--- /dev/null
+++ b/test/visual_regression/index.js
@@ -0,0 +1,26 @@
+define(function (require) {
+  require('intern/dojo/node!../support/env_setup');
+
+  const bdd = require('intern!bdd');
+  const intern = require('intern');
+  const initCallbacks = [];
+
+  function onInit(callback) {
+    initCallbacks.push(callback);
+  }
+
+  global.__kibana__intern__ = { intern, bdd, onInit };
+
+  bdd.describe('Kibana visual regressions', function () {
+    bdd.before(function () {
+      initCallbacks.forEach(callback => {
+        callback.call(this);
+      });
+    });
+
+    require([
+      'intern/dojo/node!../support/index',
+      'intern/dojo/node!./home',
+    ], function () {});
+  });
+});