Explorar el Código

Merge pull request #177 from smiclea/cypress

Add e2e integration tests using Cypress
Dorin Paslaru hace 8 años
padre
commit
c951c70eff
Se han modificado 42 ficheros con 2771 adiciones y 40 borrados
  1. 2 1
      .gitignore
  2. 3 0
      README.md
  3. 13 0
      cypress.json
  4. 6 0
      flow-typed/module_vx.x.x.js
  5. 3 0
      package.json
  6. 8 0
      private/cypress/.eslintrc
  7. 37 0
      private/cypress/config.template.js
  8. 1499 0
      private/cypress/example_spec.js
  9. 22 0
      private/cypress/integration/login/Invalid Login.js
  10. 49 0
      private/cypress/integration/migration/1 - Create Openstack Endpoint.js
  11. 42 0
      private/cypress/integration/migration/2 - Create VmWare Endpoint.js
  12. 94 0
      private/cypress/integration/migration/3 - Create VmWare Openstack Migration.js
  13. 29 0
      private/cypress/integration/migration/4 - Cancel first running migration.js
  14. 29 0
      private/cypress/integration/migration/6 - Delete first migration.js
  15. 36 0
      private/cypress/integration/migration/7 - Delete e2e Openstack endpoint.js
  16. 36 0
      private/cypress/integration/migration/8 - Delete e2e VmWare endpoint.js
  17. 42 0
      private/cypress/integration/replica/1 - Create Azure Endpoint.js
  18. 42 0
      private/cypress/integration/replica/2 - Create VmWare Endpoint.js
  19. 100 0
      private/cypress/integration/replica/3 - Create VmWare Azure Replica.js
  20. 27 0
      private/cypress/integration/replica/4 - Cancel first running replica.js
  21. 24 0
      private/cypress/integration/replica/5 - Cannot delete used endpoint.js
  22. 28 0
      private/cypress/integration/replica/6 - Delete first replica.js
  23. 36 0
      private/cypress/integration/replica/7 - Delete e2e Azure endpoint.js
  24. 36 0
      private/cypress/integration/replica/8 - Delete e2e VmWare endpoint.js
  25. 71 0
      private/cypress/integration/scheduler/Scheduler Operations.js
  26. 2 2
      src/components/atoms/DropdownButton/index.jsx
  27. 1 0
      src/components/atoms/StatusPill/index.jsx
  28. 2 0
      src/components/atoms/Switch/index.jsx
  29. 5 4
      src/components/molecules/Dropdown/index.jsx
  30. 2 0
      src/components/molecules/EndpointField/index.jsx
  31. 1 1
      src/components/molecules/EndpointListItem/index.jsx
  32. 1 1
      src/components/molecules/MainListItem/index.jsx
  33. 5 1
      src/components/molecules/ScheduleItem/index.jsx
  34. 1 0
      src/components/molecules/WizardOptionsField/index.jsx
  35. 1 1
      src/components/organisms/AlertModal/index.jsx
  36. 1 0
      src/components/organisms/ChooseProvider/index.jsx
  37. 2 4
      src/components/organisms/DetailsContentHeader/index.jsx
  38. 1 1
      src/components/organisms/Endpoint/index.jsx
  39. 1 0
      src/components/organisms/WizardEndpointList/index.jsx
  40. 1 1
      src/components/organisms/WizardInstances/index.jsx
  41. 1 1
      src/components/organisms/WizardNetworks/index.jsx
  42. 429 22
      yarn.lock

+ 2 - 1
.gitignore

@@ -5,4 +5,5 @@ dist
 node_modules
 .vscode
 flow-typed/npm/*
-!flow-typed/npm/module_vx.x.x.js
+!flow-typed/npm/module_vx.x.x.js
+private/cypress/config.js

+ 3 - 0
README.md

@@ -13,7 +13,10 @@
 
 Your server will be running at http://localhost:3000/
 
+### Testing
 
+- unit tests can be run using `yarn test`
+- e2e integration tests can be run using `yarn cypress`. First though, you have to create the `private/cypress/config.js` file using `private/cypress/config.template.js` as a template and then run `yarn build` and `node server`.
 
 #### Development mode
 - run `yarn start` to start local development server

+ 13 - 0
cypress.json

@@ -0,0 +1,13 @@
+{
+  "fileServerFolder": "private/cypress",
+  "fixturesFolder": false,
+  "integrationFolder": "private/cypress/integration",
+  "pluginsFile": false,
+  "screenshotsFolder": "private/cypress/screenshots",
+  "supportFile": false,
+  "videosFolder": "private/cypress/videos",
+  "viewportWidth": 1280,
+  "viewportHeight": 660,
+  "defaultCommandTimeout":	7000,
+  "execTimeout": 90000
+}

+ 6 - 0
flow-typed/module_vx.x.x.js

@@ -9,3 +9,9 @@ declare module 'moment/locale/en-gb' {
 declare module 'mobx' {
   declare module.exports: any;
 }
+
+// Cypress
+declare var cy: any
+declare var Cypress: any
+declare var before: any
+declare var after: any

+ 3 - 0
package.json

@@ -6,6 +6,7 @@
     "start": "npm run env:dev && node server.js --dev",
     "env:dev": "cross-env NODE_ENV=development",
     "env:prod": "cross-env NODE_ENV=production",
+    "cypress": "cypress open",
     "test": "jest",
     "storybook": "start-storybook -p 9001 -c private/storybook",
     "lint": "eslint src private webpack.config.js --ext js,jsx",
@@ -36,10 +37,12 @@
     "@storybook/react": "^3.2.15",
     "babel-eslint": "^8.0.1",
     "babel-jest": "^21.2.0",
+    "cypress": "^2.1.0",
     "enzyme": "^3.1.0",
     "enzyme-adapter-react-16": "^1.0.4",
     "eslint": "^4.8.0",
     "eslint-config-airbnb": "^15.1.0",
+    "eslint-plugin-cypress": "^2.0.1",
     "eslint-plugin-flowtype": "^2.46.1",
     "eslint-plugin-import": "^2.7.0",
     "eslint-plugin-jsx-a11y": "^6.0.2",

+ 8 - 0
private/cypress/.eslintrc

@@ -0,0 +1,8 @@
+{
+  "env": {
+    "cypress/globals": true
+  },
+  "plugins": [
+    "cypress"
+  ]
+}

+ 37 - 0
private/cypress/config.template.js

@@ -0,0 +1,37 @@
+// @flow
+
+export default {
+  nodeServer: 'http://localhost:3000/',
+  username: 'admin',
+  password: '',
+  endpoints: {
+    azure: {
+      username: '',
+      password: '',
+      subscriptionId: '',
+    },
+    vmware: {
+      username: '',
+      password: '',
+      host: '',
+    },
+    openstack: {
+      userDomainName: 'Default',
+      authUrl: '',
+      projectName: 'admin',
+      projectDomainName: '',
+      password: '',
+      username: 'admin',
+      glanceApiVersion: 2,
+      identityVersion: 3,
+    },
+  },
+  wizard: {
+    azure: {
+      location: { label: 'West US', value: 'westus' },
+      resourceGroup: { label: 'Coriolis', value: 'coriolis' },
+    },
+    instancesSearch: 'ubuntu',
+    instancesSelectItem: 2,
+  },
+}

+ 1499 - 0
private/cypress/example_spec.js

@@ -0,0 +1,1499 @@
+//
+// **** Kitchen Sink Tests ****
+//
+// This app was developed to demonstrate
+// how to write tests in Cypress utilizing
+// all of the available commands
+//
+// Feel free to modify this spec in your
+// own application as a jumping off point
+
+// Please read our "Introduction to Cypress"
+// https://on.cypress.io/introduction-to-cypress
+
+/* eslint-disable */
+
+describe('Kitchen Sink', function () {
+  it('.should() - assert that <title> is correct', function () {
+    // https://on.cypress.io/visit
+    cy.visit('https://example.cypress.io')
+
+    // Here we've made our first assertion using a '.should()' command.
+    // An assertion is comprised of a chainer, subject, and optional value.
+
+    // https://on.cypress.io/should
+    // https://on.cypress.io/and
+
+    // https://on.cypress.io/title
+    cy.title().should('include', 'Kitchen Sink')
+    //   ↲               ↲            ↲
+    // subject        chainer      value
+  })
+
+  context('Querying', function () {
+    beforeEach(function () {
+      // Visiting our app before each test removes any state build up from
+      // previous tests. Visiting acts as if we closed a tab and opened a fresh one
+      cy.visit('https://example.cypress.io/commands/querying')
+    })
+
+    // Let's query for some DOM elements and make assertions
+    // The most commonly used query is 'cy.get()', you can
+    // think of this like the '$' in jQuery
+
+    it('cy.get() - query DOM elements', function () {
+      // https://on.cypress.io/get
+
+      // Get DOM elements by id
+      cy.get('#query-btn').should('contain', 'Button')
+
+      // Get DOM elements by class
+      cy.get('.query-btn').should('contain', 'Button')
+
+      cy.get('#querying .well>button:first').should('contain', 'Button')
+      //              ↲
+      // Use CSS selectors just like jQuery
+    })
+
+    it('cy.contains() - query DOM elements with matching content', function () {
+      // https://on.cypress.io/contains
+      cy.get('.query-list')
+        .contains('bananas').should('have.class', 'third')
+
+      // we can pass a regexp to `.contains()`
+      cy.get('.query-list')
+        .contains(/^b\w+/).should('have.class', 'third')
+
+      cy.get('.query-list')
+        .contains('apples').should('have.class', 'first')
+
+      // passing a selector to contains will yield the selector containing the text
+      cy.get('#querying')
+        .contains('ul', 'oranges').should('have.class', 'query-list')
+
+      // `.contains()` will favor input[type='submit'],
+      // button, a, and label over deeper elements inside them
+      // this will not yield the <span> inside the button,
+      // but the <button> itself
+      cy.get('.query-button')
+        .contains('Save Form').should('have.class', 'btn')
+    })
+
+    it('.within() - query DOM elements within a specific element', function () {
+      // https://on.cypress.io/within
+      cy.get('.query-form').within(function () {
+        cy.get('input:first').should('have.attr', 'placeholder', 'Email')
+        cy.get('input:last').should('have.attr', 'placeholder', 'Password')
+      })
+    })
+
+    it('cy.root() - query the root DOM element', function () {
+      // https://on.cypress.io/root
+      // By default, root is the document
+      cy.root().should('match', 'html')
+
+      cy.get('.query-ul').within(function () {
+        // In this within, the root is now the ul DOM element
+        cy.root().should('have.class', 'query-ul')
+      })
+    })
+  })
+
+  context('Traversal', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/traversal')
+    })
+
+    // Let's query for some DOM elements and make assertions
+
+    it('.children() - get child DOM elements', function () {
+      // https://on.cypress.io/children
+      cy.get('.traversal-breadcrumb').children('.active')
+        .should('contain', 'Data')
+    })
+
+    it('.closest() - get closest ancestor DOM element', function () {
+      // https://on.cypress.io/closest
+      cy.get('.traversal-badge').closest('ul')
+        .should('have.class', 'list-group')
+    })
+
+    it('.eq() - get a DOM element at a specific index', function () {
+      // https://on.cypress.io/eq
+      cy.get('.traversal-list>li').eq(1).should('contain', 'siamese')
+    })
+
+    it('.filter() - get DOM elements that match the selector', function () {
+      // https://on.cypress.io/filter
+      cy.get('.traversal-nav>li').filter('.active').should('contain', 'About')
+    })
+
+    it('.find() - get descendant DOM elements of the selector', function () {
+      // https://on.cypress.io/find
+      cy.get('.traversal-pagination').find('li').find('a')
+        .should('have.length', 7)
+    })
+
+    it('.first() - get first DOM element', function () {
+      // https://on.cypress.io/first
+      cy.get('.traversal-table td').first().should('contain', '1')
+    })
+
+    it('.last() - get last DOM element', function () {
+      // https://on.cypress.io/last
+      cy.get('.traversal-buttons .btn').last().should('contain', 'Submit')
+    })
+
+    it('.next() - get next sibling DOM element', function () {
+      // https://on.cypress.io/next
+      cy.get('.traversal-ul').contains('apples').next().should('contain', 'oranges')
+    })
+
+    it('.nextAll() - get all next sibling DOM elements', function () {
+      // https://on.cypress.io/nextall
+      cy.get('.traversal-next-all').contains('oranges')
+        .nextAll().should('have.length', 3)
+    })
+
+    it('.nextUntil() - get next sibling DOM elements until next el', function () {
+      // https://on.cypress.io/nextuntil
+      cy.get('#veggies').nextUntil('#nuts').should('have.length', 3)
+    })
+
+    it('.not() - remove DOM elements from set of DOM elements', function () {
+      // https://on.cypress.io/not
+      cy.get('.traversal-disabled .btn').not('[disabled]').should('not.contain', 'Disabled')
+    })
+
+    it('.parent() - get parent DOM element from DOM elements', function () {
+      // https://on.cypress.io/parent
+      cy.get('.traversal-mark').parent().should('contain', 'Morbi leo risus')
+    })
+
+    it('.parents() - get parent DOM elements from DOM elements', function () {
+      // https://on.cypress.io/parents
+      cy.get('.traversal-cite').parents().should('match', 'blockquote')
+    })
+
+    it('.parentsUntil() - get parent DOM elements from DOM elements until el', function () {
+      // https://on.cypress.io/parentsuntil
+      cy.get('.clothes-nav').find('.active').parentsUntil('.clothes-nav')
+        .should('have.length', 2)
+    })
+
+    it('.prev() - get previous sibling DOM element', function () {
+      // https://on.cypress.io/prev
+      cy.get('.birds').find('.active').prev().should('contain', 'Lorikeets')
+    })
+
+    it('.prevAll() - get all previous sibling DOM elements', function () {
+      // https://on.cypress.io/prevAll
+      cy.get('.fruits-list').find('.third').prevAll().should('have.length', 2)
+    })
+
+    it('.prevUntil() - get all previous sibling DOM elements until el', function () {
+      // https://on.cypress.io/prevUntil
+      cy.get('.foods-list').find('#nuts').prevUntil('#veggies')
+    })
+
+    it('.siblings() - get all sibling DOM elements', function () {
+      // https://on.cypress.io/siblings
+      cy.get('.traversal-pills .active').siblings().should('have.length', 2)
+    })
+  })
+
+  context('Actions', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/actions')
+    })
+
+    // Let's perform some actions on DOM elements
+    // https://on.cypress.io/interacting-with-elements
+
+    it('.type() - type into a DOM element', function () {
+      // https://on.cypress.io/type
+      cy.get('.action-email')
+        .type('fake@email.com').should('have.value', 'fake@email.com')
+
+        // .type() with special character sequences
+        .type('{leftarrow}{rightarrow}{uparrow}{downarrow}')
+        .type('{del}{selectall}{backspace}')
+
+        // .type() with key modifiers
+        .type('{alt}{option}') //these are equivalent
+        .type('{ctrl}{control}') //these are equivalent
+        .type('{meta}{command}{cmd}') //these are equivalent
+        .type('{shift}')
+
+        // Delay each keypress by 0.1 sec
+        .type('slow.typing@email.com', { delay: 100 })
+        .should('have.value', 'slow.typing@email.com')
+
+      cy.get('.action-disabled')
+        // Ignore error checking prior to type
+        // like whether the input is visible or disabled
+        .type('disabled error checking', { force: true })
+        .should('have.value', 'disabled error checking')
+    })
+
+    it('.focus() - focus on a DOM element', function () {
+      // https://on.cypress.io/focus
+      cy.get('.action-focus').focus()
+        .should('have.class', 'focus')
+        .prev().should('have.attr', 'style', 'color: orange;')
+    })
+
+    it('.blur() - blur off a DOM element', function () {
+      // https://on.cypress.io/blur
+      cy.get('.action-blur').type('I\'m about to blur').blur()
+        .should('have.class', 'error')
+        .prev().should('have.attr', 'style', 'color: red;')
+    })
+
+    it('.clear() - clears an input or textarea element', function () {
+      // https://on.cypress.io/clear
+      cy.get('.action-clear').type('We are going to clear this text')
+        .should('have.value', 'We are going to clear this text')
+        .clear()
+        .should('have.value', '')
+    })
+
+    it('.submit() - submit a form', function () {
+      // https://on.cypress.io/submit
+      cy.get('.action-form')
+        .find('[type="text"]').type('HALFOFF')
+      cy.get('.action-form').submit()
+        .next().should('contain', 'Your form has been submitted!')
+    })
+
+    it('.click() - click on a DOM element', function () {
+      // https://on.cypress.io/click
+      cy.get('.action-btn').click()
+
+      // You can click on 9 specific positions of an element:
+      //  -----------------------------------
+      // | topLeft        top       topRight |
+      // |                                   |
+      // |                                   |
+      // |                                   |
+      // | left          center        right |
+      // |                                   |
+      // |                                   |
+      // |                                   |
+      // | bottomLeft   bottom   bottomRight |
+      //  -----------------------------------
+
+      // clicking in the center of the element is the default
+      cy.get('#action-canvas').click()
+
+      cy.get('#action-canvas').click('topLeft')
+      cy.get('#action-canvas').click('top')
+      cy.get('#action-canvas').click('topRight')
+      cy.get('#action-canvas').click('left')
+      cy.get('#action-canvas').click('right')
+      cy.get('#action-canvas').click('bottomLeft')
+      cy.get('#action-canvas').click('bottom')
+      cy.get('#action-canvas').click('bottomRight')
+
+      // .click() accepts an x and y coordinate
+      // that controls where the click occurs :)
+
+      cy.get('#action-canvas')
+        .click(80, 75) // click 80px on x coord and 75px on y coord
+        .click(170, 75)
+        .click(80, 165)
+        .click(100, 185)
+        .click(125, 190)
+        .click(150, 185)
+        .click(170, 165)
+
+      // click multiple elements by passing multiple: true
+      cy.get('.action-labels>.label').click({ multiple: true })
+
+      // Ignore error checking prior to clicking
+      // like whether the element is visible, clickable or disabled
+      // this button below is covered by another element.
+      cy.get('.action-opacity>.btn').click({ force: true })
+    })
+
+    it('.dblclick() - double click on a DOM element', function () {
+      // Our app has a listener on 'dblclick' event in our 'scripts.js'
+      // that hides the div and shows an input on double click
+
+      // https://on.cypress.io/dblclick
+      cy.get('.action-div').dblclick().should('not.be.visible')
+      cy.get('.action-input-hidden').should('be.visible')
+    })
+
+    it('cy.check() - check a checkbox or radio element', function () {
+      // By default, .check() will check all
+      // matching checkbox or radio elements in succession, one after another
+
+      // https://on.cypress.io/check
+      cy.get('.action-checkboxes [type="checkbox"]').not('[disabled]')
+        .check().should('be.checked')
+
+      cy.get('.action-radios [type="radio"]').not('[disabled]')
+        .check().should('be.checked')
+
+      // .check() accepts a value argument
+      // that checks only checkboxes or radios
+      // with matching values
+      cy.get('.action-radios [type="radio"]').check('radio1').should('be.checked')
+
+      // .check() accepts an array of values
+      // that checks only checkboxes or radios
+      // with matching values
+      cy.get('.action-multiple-checkboxes [type="checkbox"]')
+        .check(['checkbox1', 'checkbox2']).should('be.checked')
+
+      // Ignore error checking prior to checking
+      // like whether the element is visible, clickable or disabled
+      // this checkbox below is disabled.
+      cy.get('.action-checkboxes [disabled]')
+        .check({ force: true }).should('be.checked')
+
+      cy.get('.action-radios [type="radio"]')
+        .check('radio3', { force: true }).should('be.checked')
+    })
+
+    it('.uncheck() - uncheck a checkbox element', function () {
+      // By default, .uncheck() will uncheck all matching
+      // checkbox elements in succession, one after another
+
+      // https://on.cypress.io/uncheck
+      cy.get('.action-check [type="checkbox"]')
+        .not('[disabled]')
+        .uncheck().should('not.be.checked')
+
+      // .uncheck() accepts a value argument
+      // that unchecks only checkboxes
+      // with matching values
+      cy.get('.action-check [type="checkbox"]')
+        .check('checkbox1')
+        .uncheck('checkbox1').should('not.be.checked')
+
+      // .uncheck() accepts an array of values
+      // that unchecks only checkboxes or radios
+      // with matching values
+      cy.get('.action-check [type="checkbox"]')
+        .check(['checkbox1', 'checkbox3'])
+        .uncheck(['checkbox1', 'checkbox3']).should('not.be.checked')
+
+      // Ignore error checking prior to unchecking
+      // like whether the element is visible, clickable or disabled
+      // this checkbox below is disabled.
+      cy.get('.action-check [disabled]')
+        .uncheck({ force: true }).should('not.be.checked')
+    })
+
+    it('.select() - select an option in a <select> element', function () {
+      // https://on.cypress.io/select
+
+      // Select option with matching text content
+      cy.get('.action-select').select('apples')
+
+      // Select option with matching value
+      cy.get('.action-select').select('fr-bananas')
+
+      // Select options with matching text content
+      cy.get('.action-select-multiple')
+        .select(['apples', 'oranges', 'bananas'])
+
+      // Select options with matching values
+      cy.get('.action-select-multiple')
+        .select(['fr-apples', 'fr-oranges', 'fr-bananas'])
+    })
+
+    it('.scrollIntoView() - scroll an element into view', function () {
+      // https://on.cypress.io/scrollintoview
+
+      // normally all of these buttons are hidden, because they're not within
+      // the viewable area of their parent (we need to scroll to see them)
+      cy.get('#scroll-horizontal button')
+        .should('not.be.visible')
+
+      // scroll the button into view, as if the user had scrolled
+      cy.get('#scroll-horizontal button').scrollIntoView()
+        .should('be.visible')
+
+      cy.get('#scroll-vertical button')
+        .should('not.be.visible')
+
+      // Cypress handles the scroll direction needed
+      cy.get('#scroll-vertical button').scrollIntoView()
+        .should('be.visible')
+
+      cy.get('#scroll-both button')
+        .should('not.be.visible')
+
+      // Cypress knows to scroll to the right and down
+      cy.get('#scroll-both button').scrollIntoView()
+        .should('be.visible')
+    })
+
+    it('cy.scrollTo() - scroll the window or element to a position', function () {
+
+      // https://on.cypress.io/scrollTo
+
+      // You can scroll to 9 specific positions of an element:
+      //  -----------------------------------
+      // | topLeft        top       topRight |
+      // |                                   |
+      // |                                   |
+      // |                                   |
+      // | left          center        right |
+      // |                                   |
+      // |                                   |
+      // |                                   |
+      // | bottomLeft   bottom   bottomRight |
+      //  -----------------------------------
+
+      // if you chain .scrollTo() off of cy, we will
+      // scroll the entire window
+      cy.scrollTo('bottom')
+
+      cy.get('#scrollable-horizontal').scrollTo('right')
+
+      // or you can scroll to a specific coordinate:
+      // (x axis, y axis) in pixels
+      cy.get('#scrollable-vertical').scrollTo(250, 250)
+
+      // or you can scroll to a specific percentage
+      // of the (width, height) of the element
+      cy.get('#scrollable-both').scrollTo('75%', '25%')
+
+      // control the easing of the scroll (default is 'swing')
+      cy.get('#scrollable-vertical').scrollTo('center', { easing: 'linear' })
+
+      // control the duration of the scroll (in ms)
+      cy.get('#scrollable-both').scrollTo('center', { duration: 2000 })
+    })
+
+    it('.trigger() - trigger an event on a DOM element', function () {
+      // To interact with a range input (slider), we need to set its value and
+      // then trigger the appropriate event to signal it has changed
+
+      // Here, we invoke jQuery's val() method to set the value
+      // and trigger the 'change' event
+
+      // Note that some implementations may rely on the 'input' event,
+      // which is fired as a user moves the slider, but is not supported
+      // by some browsers
+
+      // https://on.cypress.io/trigger
+      cy.get('.trigger-input-range')
+        .invoke('val', 25)
+        .trigger('change')
+        .get('input[type=range]').siblings('p')
+        .should('have.text', '25')
+
+      // See our example recipes for more examples of using trigger
+      // https://on.cypress.io/examples
+    })
+  })
+
+  context('Window', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/window')
+    })
+
+    it('cy.window() - get the global window object', function () {
+      // https://on.cypress.io/window
+      cy.window().should('have.property', 'top')
+    })
+
+    it('cy.document() - get the document object', function () {
+      // https://on.cypress.io/document
+      cy.document().should('have.property', 'charset').and('eq', 'UTF-8')
+    })
+
+    it('cy.title() - get the title', function () {
+      // https://on.cypress.io/title
+      cy.title().should('include', 'Kitchen Sink')
+    })
+  })
+
+  context('Viewport', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/viewport')
+    })
+
+    it('cy.viewport() - set the viewport size and dimension', function () {
+
+      cy.get('#navbar').should('be.visible')
+
+      // https://on.cypress.io/viewport
+      cy.viewport(320, 480)
+
+      // the navbar should have collapse since our screen is smaller
+      cy.get('#navbar').should('not.be.visible')
+      cy.get('.navbar-toggle').should('be.visible').click()
+      cy.get('.nav').find('a').should('be.visible')
+
+      // lets see what our app looks like on a super large screen
+      cy.viewport(2999, 2999)
+
+      // cy.viewport() accepts a set of preset sizes
+      // to easily set the screen to a device's width and height
+
+      // We added a cy.wait() between each viewport change so you can see
+      // the change otherwise it's a little too fast to see :)
+
+      cy.viewport('macbook-15')
+      cy.wait(200)
+      cy.viewport('macbook-13')
+      cy.wait(200)
+      cy.viewport('macbook-11')
+      cy.wait(200)
+      cy.viewport('ipad-2')
+      cy.wait(200)
+      cy.viewport('ipad-mini')
+      cy.wait(200)
+      cy.viewport('iphone-6+')
+      cy.wait(200)
+      cy.viewport('iphone-6')
+      cy.wait(200)
+      cy.viewport('iphone-5')
+      cy.wait(200)
+      cy.viewport('iphone-4')
+      cy.wait(200)
+      cy.viewport('iphone-3')
+      cy.wait(200)
+
+      // cy.viewport() accepts an orientation for all presets
+      // the default orientation is 'portrait'
+      cy.viewport('ipad-2', 'portrait')
+      cy.wait(200)
+      cy.viewport('iphone-4', 'landscape')
+      cy.wait(200)
+
+      // The viewport will be reset back to the default dimensions
+      // in between tests (the  default is set in cypress.json)
+    })
+  })
+
+  context('Location', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/location')
+    })
+
+    // We look at the url to make assertions
+    // about the page's state
+
+    it('cy.hash() - get the current URL hash', function () {
+      // https://on.cypress.io/hash
+      cy.hash().should('be.empty')
+    })
+
+    it('cy.location() - get window.location', function () {
+      // https://on.cypress.io/location
+      cy.location().should(function (location) {
+        expect(location.hash).to.be.empty
+        expect(location.href).to.eq('https://example.cypress.io/commands/location')
+        expect(location.host).to.eq('example.cypress.io')
+        expect(location.hostname).to.eq('example.cypress.io')
+        expect(location.origin).to.eq('https://example.cypress.io')
+        expect(location.pathname).to.eq('/commands/location')
+        expect(location.port).to.eq('')
+        expect(location.protocol).to.eq('https:')
+        expect(location.search).to.be.empty
+      })
+    })
+
+    it('cy.url() - get the current URL', function () {
+      // https://on.cypress.io/url
+      cy.url().should('eq', 'https://example.cypress.io/commands/location')
+    })
+  })
+
+  context('Navigation', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io')
+      cy.get('.navbar-nav').contains('Commands').click()
+      cy.get('.dropdown-menu').contains('Navigation').click()
+    })
+
+    it('cy.go() - go back or forward in the browser\'s history', function () {
+      cy.location('pathname').should('include', 'navigation')
+
+      // https://on.cypress.io/go
+      cy.go('back')
+      cy.location('pathname').should('not.include', 'navigation')
+
+      cy.go('forward')
+      cy.location('pathname').should('include', 'navigation')
+
+      // equivalent to clicking back
+      cy.go(-1)
+      cy.location('pathname').should('not.include', 'navigation')
+
+      // equivalent to clicking forward
+      cy.go(1)
+      cy.location('pathname').should('include', 'navigation')
+    })
+
+    it('cy.reload() - reload the page', function () {
+      // https://on.cypress.io/reload
+      cy.reload()
+
+      // reload the page without using the cache
+      cy.reload(true)
+    })
+
+    it('cy.visit() - visit a remote url', function () {
+      // Visit any sub-domain of your current domain
+      // https://on.cypress.io/visit
+
+      // Pass options to the visit
+      cy.visit('https://example.cypress.io/commands/navigation', {
+        timeout: 50000, // increase total time for the visit to resolve
+        onBeforeLoad (contentWindow) {
+          // contentWindow is the remote page's window object
+        },
+        onLoad (contentWindow) {
+          // contentWindow is the remote page's window object
+        },
+      })
+      })
+  })
+
+  context('Assertions', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/assertions')
+    })
+
+    describe('Implicit Assertions', function () {
+
+      it('.should() - make an assertion about the current subject', function () {
+        // https://on.cypress.io/should
+        cy.get('.assertion-table')
+          .find('tbody tr:last').should('have.class', 'success')
+      })
+
+      it('.and() - chain multiple assertions together', function () {
+        // https://on.cypress.io/and
+        cy.get('.assertions-link')
+          .should('have.class', 'active')
+          .and('have.attr', 'href')
+          .and('include', 'cypress.io')
+      })
+    })
+
+    describe('Explicit Assertions', function () {
+      // https://on.cypress.io/assertions
+      it('expect - assert shape of an object', function () {
+        const person = {
+          name: 'Joe',
+          age: 20,
+        }
+        expect(person).to.have.all.keys('name', 'age')
+      })
+
+      it('expect - make an assertion about a specified subject', function () {
+        // We can use Chai's BDD style assertions
+        expect(true).to.be.true
+
+        // Pass a function to should that can have any number
+        // of explicit assertions within it.
+        cy.get('.assertions-p').find('p')
+        .should(function ($p) {
+          // return an array of texts from all of the p's
+          let texts = $p.map(function (i, el) {
+            // https://on.cypress.io/$
+            return Cypress.$(el).text()
+          })
+
+          // jquery map returns jquery object
+          // and .get() convert this to simple array
+          texts = texts.get()
+
+          // array should have length of 3
+          expect(texts).to.have.length(3)
+
+          // set this specific subject
+          expect(texts).to.deep.eq([
+            'Some text from first p',
+            'More text from second p',
+            'And even more text from third p',
+          ])
+        })
+      })
+    })
+  })
+
+  context('Misc', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/misc')
+    })
+
+    it('.end() - end the command chain', function () {
+      // cy.end is useful when you want to end a chain of commands
+      // and force Cypress to re-query from the root element
+
+      // https://on.cypress.io/end
+      cy.get('.misc-table').within(function () {
+        // ends the current chain and yields null
+        cy.contains('Cheryl').click().end()
+
+        // queries the entire table again
+        cy.contains('Charles').click()
+      })
+    })
+
+    it('cy.exec() - execute a system command', function () {
+      // cy.exec allows you to execute a system command.
+      // so you can take actions necessary for your test,
+      // but outside the scope of Cypress.
+
+      // https://on.cypress.io/exec
+      cy.exec('echo Jane Lane')
+        .its('stdout').should('contain', 'Jane Lane')
+
+      // we can use Cypress.platform string to
+      // select appropriate command
+      // https://on.cypress/io/platform
+      cy.log(`Platform ${Cypress.platform} architecture ${Cypress.arch}`)
+
+      if (Cypress.platform === 'win32') {
+        cy.exec('print cypress.json')
+          .its('stderr').should('be.empty')
+      } else {
+        cy.exec('cat cypress.json')
+          .its('stderr').should('be.empty')
+
+        cy.exec('pwd')
+          .its('code').should('eq', 0)
+      }
+    })
+
+    it('cy.focused() - get the DOM element that has focus', function () {
+      // https://on.cypress.io/focused
+      cy.get('.misc-form').find('#name').click()
+      cy.focused().should('have.id', 'name')
+
+      cy.get('.misc-form').find('#description').click()
+      cy.focused().should('have.id', 'description')
+    })
+
+    it('cy.screenshot() - take a screenshot', function () {
+      // https://on.cypress.io/screenshot
+      cy.screenshot('my-image')
+    })
+
+    it('cy.wrap() - wrap an object', function () {
+      // https://on.cypress.io/wrap
+      cy.wrap({ foo: 'bar' })
+        .should('have.property', 'foo')
+        .and('include', 'bar')
+    })
+  })
+
+  context('Connectors', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/connectors')
+    })
+
+    it('.each() - iterate over an array of elements', function () {
+      // https://on.cypress.io/each
+      cy.get('.connectors-each-ul>li')
+        .each(function ($el, index, $list) {
+          console.log($el, index, $list)
+        })
+    })
+
+    it('.its() - get properties on the current subject', function () {
+      // https://on.cypress.io/its
+      cy.get('.connectors-its-ul>li')
+        // calls the 'length' property yielding that value
+        .its('length')
+        .should('be.gt', 2)
+    })
+
+    it('.invoke() - invoke a function on the current subject', function () {
+      // our div is hidden in our script.js
+      // $('.connectors-div').hide()
+
+      // https://on.cypress.io/invoke
+      cy.get('.connectors-div').should('be.hidden')
+
+        // call the jquery method 'show' on the 'div.container'
+        .invoke('show')
+        .should('be.visible')
+    })
+
+    it('.spread() - spread an array as individual args to callback function', function () {
+      // https://on.cypress.io/spread
+      let arr = ['foo', 'bar', 'baz']
+
+      cy.wrap(arr).spread(function (foo, bar, baz) {
+        expect(foo).to.eq('foo')
+        expect(bar).to.eq('bar')
+        expect(baz).to.eq('baz')
+      })
+    })
+
+    it('.then() - invoke a callback function with the current subject', function () {
+      // https://on.cypress.io/then
+      cy.get('.connectors-list>li').then(function ($lis) {
+        expect($lis).to.have.length(3)
+        expect($lis.eq(0)).to.contain('Walk the dog')
+        expect($lis.eq(1)).to.contain('Feed the cat')
+        expect($lis.eq(2)).to.contain('Write JavaScript')
+      })
+    })
+  })
+
+  context('Aliasing', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/aliasing')
+    })
+
+    // We alias a DOM element for use later
+    // We don't have to traverse to the element
+    // later in our code, we just reference it with @
+
+    it('.as() - alias a route or DOM element for later use', function () {
+      // this is a good use case for an alias,
+      // we don't want to write this long traversal again
+
+      // https://on.cypress.io/as
+      cy.get('.as-table').find('tbody>tr')
+        .first().find('td').first().find('button').as('firstBtn')
+
+      // maybe do some more testing here...
+
+      // when we reference the alias, we place an
+      // @ in front of it's name
+      cy.get('@firstBtn').click()
+
+      cy.get('@firstBtn')
+        .should('have.class', 'btn-success')
+        .and('contain', 'Changed')
+    })
+  })
+
+  context('Waiting', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/waiting')
+    })
+    // BE CAREFUL of adding unnecessary wait times.
+
+    // https://on.cypress.io/wait
+    it('cy.wait() - wait for a specific amount of time', function () {
+      cy.get('.wait-input1').type('Wait 1000ms after typing')
+      cy.wait(1000)
+      cy.get('.wait-input2').type('Wait 1000ms after typing')
+      cy.wait(1000)
+      cy.get('.wait-input3').type('Wait 1000ms after typing')
+      cy.wait(1000)
+    })
+
+    // Waiting for a specific resource to resolve
+    // is covered within the cy.route() test below
+  })
+
+  context('Network Requests', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/network-requests')
+    })
+
+    // Manage AJAX / XHR requests in your app
+
+    it('cy.server() - control behavior of network requests and responses', function () {
+      // https://on.cypress.io/server
+      cy.server().should(function (server) {
+        // the default options on server
+        // you can override any of these options
+        expect(server.delay).to.eq(0)
+        expect(server.method).to.eq('GET')
+        expect(server.status).to.eq(200)
+        expect(server.headers).to.be.null
+        expect(server.response).to.be.null
+        expect(server.onRequest).to.be.undefined
+        expect(server.onResponse).to.be.undefined
+        expect(server.onAbort).to.be.undefined
+
+        // These options control the server behavior
+        // affecting all requests
+
+        // pass false to disable existing route stubs
+        expect(server.enable).to.be.true
+        // forces requests that don't match your routes to 404
+        expect(server.force404).to.be.false
+        // whitelists requests from ever being logged or stubbed
+        expect(server.whitelist).to.be.a('function')
+      })
+
+      cy.server({
+        method: 'POST',
+        delay: 1000,
+        status: 422,
+        response: {},
+      })
+
+      // any route commands will now inherit the above options
+      // from the server. anything we pass specifically
+      // to route will override the defaults though.
+    })
+
+    it('cy.request() - make an XHR request', function () {
+      // https://on.cypress.io/request
+      cy.request('https://jsonplaceholder.typicode.com/comments')
+        .should(function (response) {
+          expect(response.status).to.eq(200)
+          expect(response.body).to.have.length(500)
+          expect(response).to.have.property('headers')
+          expect(response).to.have.property('duration')
+        })
+    })
+
+    it('cy.route() - route responses to matching requests', function () {
+      let message = 'whoa, this comment doesn\'t exist'
+      cy.server()
+
+      // **** GET comments route ****
+
+      // https://on.cypress.io/route
+      cy.route(/comments\/1/).as('getComment')
+
+      // we have code that fetches a comment when
+      // the button is clicked in scripts.js
+      cy.get('.network-btn').click()
+
+      // **** Wait ****
+
+      // Wait for a specific resource to resolve
+      // continuing to the next command
+
+      // https://on.cypress.io/wait
+      cy.wait('@getComment').its('status').should('eq', 200)
+
+      // **** POST comment route ****
+
+      // Specify the route to listen to method 'POST'
+      cy.route('POST', '/comments').as('postComment')
+
+      // we have code that posts a comment when
+      // the button is clicked in scripts.js
+      cy.get('.network-post').click()
+      cy.wait('@postComment')
+
+      // get the route
+      cy.get('@postComment').then(function (xhr) {
+        expect(xhr.requestBody).to.include('email')
+        expect(xhr.requestHeaders).to.have.property('Content-Type')
+        expect(xhr.responseBody).to.have.property('name', 'Using POST in cy.route()')
+      })
+
+      // **** Stubbed PUT comment route ****
+      cy.route({
+        method: 'PUT',
+        url: /comments\/\d+/,
+        status: 404,
+        response: { error: message },
+        delay: 500,
+      }).as('putComment')
+
+      // we have code that puts a comment when
+      // the button is clicked in scripts.js
+      cy.get('.network-put').click()
+
+      cy.wait('@putComment')
+
+      // our 404 statusCode logic in scripts.js executed
+      cy.get('.network-put-comment').should('contain', message)
+    })
+  })
+
+  context('Files', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/files')
+    })
+    it('cy.fixture() - load a fixture', function () {
+      // Instead of writing a response inline you can
+      // connect a response with a fixture file
+      // located in fixtures folder.
+
+      cy.server()
+
+      // https://on.cypress.io/fixture
+      cy.fixture('example.json').as('comment')
+
+      cy.route(/comments/, '@comment').as('getComment')
+
+      // we have code that gets a comment when
+      // the button is clicked in scripts.js
+      cy.get('.fixture-btn').click()
+
+      cy.wait('@getComment').its('responseBody')
+        .should('have.property', 'name')
+        .and('include', 'Using fixtures to represent data')
+
+      // you can also just write the fixture in the route
+      cy.route(/comments/, 'fixture:example.json').as('getComment')
+
+      // we have code that gets a comment when
+      // the button is clicked in scripts.js
+      cy.get('.fixture-btn').click()
+
+      cy.wait('@getComment').its('responseBody')
+        .should('have.property', 'name')
+        .and('include', 'Using fixtures to represent data')
+
+      // or write fx to represent fixture
+      // by default it assumes it's .json
+      cy.route(/comments/, 'fx:example').as('getComment')
+
+      // we have code that gets a comment when
+      // the button is clicked in scripts.js
+      cy.get('.fixture-btn').click()
+
+      cy.wait('@getComment').its('responseBody')
+        .should('have.property', 'name')
+        .and('include', 'Using fixtures to represent data')
+    })
+
+    it('cy.readFile() - read a files contents', function () {
+      // You can read a file and yield its contents
+      // The filePath is relative to your project's root.
+
+      // https://on.cypress.io/readfile
+      cy.readFile('cypress.json').then(function (json) {
+        expect(json).to.be.an('object')
+      })
+
+    })
+
+    it('cy.writeFile() - write to a file', function () {
+      // You can write to a file with the specified contents
+
+      // Use a response from a request to automatically
+      // generate a fixture file for use later
+      cy.request('https://jsonplaceholder.typicode.com/users')
+        .then(function (response) {
+          // https://on.cypress.io/writefile
+          cy.writeFile('cypress/fixtures/users.json', response.body)
+        })
+      cy.fixture('users').should(function (users) {
+        expect(users[0].name).to.exist
+      })
+
+      // JavaScript arrays and objects are stringified and formatted into text.
+      cy.writeFile('cypress/fixtures/profile.json', {
+        id: 8739,
+        name: 'Jane',
+        email: 'jane@example.com',
+      })
+
+      cy.fixture('profile').should(function (profile) {
+        expect(profile.name).to.eq('Jane')
+      })
+    })
+  })
+
+  context('Local Storage', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/commands/local-storage')
+    })
+    // Although local storage is automatically cleared
+    // to maintain a clean state in between tests
+    // sometimes we need to clear the local storage manually
+
+    it('cy.clearLocalStorage() - clear all data in local storage', function () {
+      // https://on.cypress.io/clearlocalstorage
+      cy.get('.ls-btn').click().should(function () {
+        expect(localStorage.getItem('prop1')).to.eq('red')
+        expect(localStorage.getItem('prop2')).to.eq('blue')
+        expect(localStorage.getItem('prop3')).to.eq('magenta')
+      })
+
+      // clearLocalStorage() yields the localStorage object
+      cy.clearLocalStorage().should(function (ls) {
+        expect(ls.getItem('prop1')).to.be.null
+        expect(ls.getItem('prop2')).to.be.null
+        expect(ls.getItem('prop3')).to.be.null
+      })
+
+      // **** Clear key matching string in Local Storage ****
+      cy.get('.ls-btn').click().should(function () {
+        expect(localStorage.getItem('prop1')).to.eq('red')
+        expect(localStorage.getItem('prop2')).to.eq('blue')
+        expect(localStorage.getItem('prop3')).to.eq('magenta')
+      })
+
+      cy.clearLocalStorage('prop1').should(function (ls) {
+        expect(ls.getItem('prop1')).to.be.null
+        expect(ls.getItem('prop2')).to.eq('blue')
+        expect(ls.getItem('prop3')).to.eq('magenta')
+      })
+
+      // **** Clear key's matching regex in Local Storage ****
+      cy.get('.ls-btn').click().should(function () {
+        expect(localStorage.getItem('prop1')).to.eq('red')
+        expect(localStorage.getItem('prop2')).to.eq('blue')
+        expect(localStorage.getItem('prop3')).to.eq('magenta')
+      })
+
+      cy.clearLocalStorage(/prop1|2/).should(function (ls) {
+        expect(ls.getItem('prop1')).to.be.null
+        expect(ls.getItem('prop2')).to.be.null
+        expect(ls.getItem('prop3')).to.eq('magenta')
+      })
+    })
+  })
+
+  context('Cookies', function () {
+    beforeEach(function () {
+      Cypress.Cookies.debug(true)
+
+      cy.visit('https://example.cypress.io/commands/cookies')
+
+      // clear cookies again after visiting to remove
+      // any 3rd party cookies picked up such as cloudflare
+      cy.clearCookies()
+    })
+
+    it('cy.getCookie() - get a browser cookie', function () {
+      // https://on.cypress.io/getcookie
+      cy.get('#getCookie .set-a-cookie').click()
+
+      // cy.getCookie() yields a cookie object
+      cy.getCookie('token').should('have.property', 'value', '123ABC')
+    })
+
+    it('cy.getCookies() - get browser cookies', function () {
+      // https://on.cypress.io/getcookies
+      cy.getCookies().should('be.empty')
+
+      cy.get('#getCookies .set-a-cookie').click()
+
+      // cy.getCookies() yields an array of cookies
+      cy.getCookies().should('have.length', 1).should(function (cookies) {
+
+        // each cookie has these properties
+        expect(cookies[0]).to.have.property('name', 'token')
+        expect(cookies[0]).to.have.property('value', '123ABC')
+        expect(cookies[0]).to.have.property('httpOnly', false)
+        expect(cookies[0]).to.have.property('secure', false)
+        expect(cookies[0]).to.have.property('domain')
+        expect(cookies[0]).to.have.property('path')
+      })
+    })
+
+    it('cy.setCookie() - set a browser cookie', function () {
+      // https://on.cypress.io/setcookie
+      cy.getCookies().should('be.empty')
+
+      cy.setCookie('foo', 'bar')
+
+      // cy.getCookie() yields a cookie object
+      cy.getCookie('foo').should('have.property', 'value', 'bar')
+    })
+
+    it('cy.clearCookie() - clear a browser cookie', function () {
+      // https://on.cypress.io/clearcookie
+      cy.getCookie('token').should('be.null')
+
+      cy.get('#clearCookie .set-a-cookie').click()
+
+      cy.getCookie('token').should('have.property', 'value', '123ABC')
+
+      // cy.clearCookies() yields null
+      cy.clearCookie('token').should('be.null')
+
+      cy.getCookie('token').should('be.null')
+    })
+
+    it('cy.clearCookies() - clear browser cookies', function () {
+      // https://on.cypress.io/clearcookies
+      cy.getCookies().should('be.empty')
+
+      cy.get('#clearCookies .set-a-cookie').click()
+
+      cy.getCookies().should('have.length', 1)
+
+      // cy.clearCookies() yields null
+      cy.clearCookies()
+
+      cy.getCookies().should('be.empty')
+    })
+  })
+
+  context('Spies, Stubs, and Clock', function () {
+    it('cy.spy() - wrap a method in a spy', function () {
+      // https://on.cypress.io/spy
+      cy.visit('https://example.cypress.io/commands/spies-stubs-clocks')
+
+      let obj = {
+        foo () {},
+      }
+
+      let spy = cy.spy(obj, 'foo').as('anyArgs')
+
+      obj.foo()
+
+      expect(spy).to.be.called
+
+    })
+
+    it('cy.stub() - create a stub and/or replace a function with a stub', function () {
+      // https://on.cypress.io/stub
+      cy.visit('https://example.cypress.io/commands/spies-stubs-clocks')
+
+      let obj = {
+        foo () {},
+      }
+
+      let stub = cy.stub(obj, 'foo').as('foo')
+
+      obj.foo('foo', 'bar')
+
+      expect(stub).to.be.called
+
+    })
+
+    it('cy.clock() - control time in the browser', function () {
+      // create the date in UTC so its always the same
+      // no matter what local timezone the browser is running in
+      let now = new Date(Date.UTC(2017, 2, 14)).getTime()
+
+      // https://on.cypress.io/clock
+      cy.clock(now)
+      cy.visit('https://example.cypress.io/commands/spies-stubs-clocks')
+      cy.get('#clock-div').click()
+        .should('have.text', '1489449600')
+    })
+
+    it('cy.tick() - move time in the browser', function () {
+      // create the date in UTC so its always the same
+      // no matter what local timezone the browser is running in
+      let now = new Date(Date.UTC(2017, 2, 14)).getTime()
+
+      // https://on.cypress.io/tick
+      cy.clock(now)
+      cy.visit('https://example.cypress.io/commands/spies-stubs-clocks')
+      cy.get('#tick-div').click()
+        .should('have.text', '1489449600')
+      cy.tick(10000) // 10 seconds passed
+      cy.get('#tick-div').click()
+        .should('have.text', '1489449610')
+    })
+  })
+
+  context('Utilities', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/utilities')
+    })
+
+    it('Cypress._.method() - call a lodash method', function () {
+      // use the _.chain, _.map, _.take, and _.value functions
+      // https://on.cypress.io/_
+      cy.request('https://jsonplaceholder.typicode.com/users')
+        .then(function (response) {
+          let ids = Cypress._.chain(response.body).map('id').take(3).value()
+
+          expect(ids).to.deep.eq([1, 2, 3])
+        })
+    })
+
+    it('Cypress.$(selector) - call a jQuery method', function () {
+      // https://on.cypress.io/$
+      let $li = Cypress.$('.utility-jquery li:first')
+
+      cy.wrap($li)
+        .should('not.have.class', 'active')
+        .click()
+        .should('have.class', 'active')
+    })
+
+    it('Cypress.moment() - format or parse dates using a moment method', function () {
+      // use moment's format function
+      // https://on.cypress.io/cypress-moment
+      let time = Cypress.moment().utc('2014-04-25T19:38:53.196Z').format('h:mm A')
+
+      cy.get('.utility-moment').contains('3:38 PM')
+        .should('have.class', 'badge')
+    })
+
+    it('Cypress.Blob.method() - blob utilities and base64 string conversion', function () {
+      cy.get('.utility-blob').then(function ($div) {
+        // https://on.cypress.io/blob
+        // https://github.com/nolanlawson/blob-util#imgSrcToDataURL
+        // get the dataUrl string for the javascript-logo
+        return Cypress.Blob.imgSrcToDataURL('https://example.cypress.io/assets/img/javascript-logo.png', undefined, 'anonymous')
+          .then(function (dataUrl) {
+            // create an <img> element and set its src to the dataUrl
+            let img = Cypress.$('<img />', { src: dataUrl })
+            // need to explicitly return cy here since we are initially returning
+            // the Cypress.Blob.imgSrcToDataURL promise to our test
+            // append the image
+            $div.append(img)
+
+            cy.get('.utility-blob img').click()
+            .should('have.attr', 'src', dataUrl)
+          })
+      })
+    })
+
+    it('new Cypress.Promise(function) - instantiate a bluebird promise', function () {
+      // https://on.cypress.io/promise
+      let waited = false
+
+      function waitOneSecond () {
+        // return a promise that resolves after 1 second
+        return new Cypress.Promise(function (resolve, reject) {
+          setTimeout(function () {
+            // set waited to true
+            waited = true
+
+            // resolve with 'foo' string
+            resolve('foo')
+          }, 1000)
+        })
+      }
+
+      cy.then(function () {
+        // return a promise to cy.then() that
+        // is awaited until it resolves
+        return waitOneSecond().then(function (str) {
+          expect(str).to.eq('foo')
+          expect(waited).to.be.true
+        })
+      })
+    })
+  })
+
+
+  context('Cypress.config()', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/cypress-api/config')
+    })
+
+    it('Cypress.config() - get and set configuration options', function () {
+      // https://on.cypress.io/config
+      let myConfig = Cypress.config()
+
+      expect(myConfig).to.have.property('animationDistanceThreshold', 5)
+      expect(myConfig).to.have.property('baseUrl', null)
+      expect(myConfig).to.have.property('defaultCommandTimeout', 4000)
+      expect(myConfig).to.have.property('requestTimeout', 5000)
+      expect(myConfig).to.have.property('responseTimeout', 30000)
+      expect(myConfig).to.have.property('viewportHeight', 660)
+      expect(myConfig).to.have.property('viewportWidth', 1000)
+      expect(myConfig).to.have.property('pageLoadTimeout', 60000)
+      expect(myConfig).to.have.property('waitForAnimations', true)
+
+      expect(Cypress.config('pageLoadTimeout')).to.eq(60000)
+
+      // this will change the config for the rest of your tests!
+      Cypress.config('pageLoadTimeout', 20000)
+
+      expect(Cypress.config('pageLoadTimeout')).to.eq(20000)
+
+      Cypress.config('pageLoadTimeout', 60000)
+    })
+  })
+
+  context('Cypress.env()', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/cypress-api/env')
+    })
+
+    // We can set environment variables for highly dynamic values
+
+    // https://on.cypress.io/environment-variables
+    it('Cypress.env() - get environment variables', function () {
+      // https://on.cypress.io/env
+      // set multiple environment variables
+      Cypress.env({
+        host: 'veronica.dev.local',
+        api_server: 'http://localhost:8888/v1/',
+      })
+
+      // get environment variable
+      expect(Cypress.env('host')).to.eq('veronica.dev.local')
+
+      // set environment variable
+      Cypress.env('api_server', 'http://localhost:8888/v2/')
+      expect(Cypress.env('api_server')).to.eq('http://localhost:8888/v2/')
+
+      // get all environment variable
+      expect(Cypress.env()).to.have.property('host', 'veronica.dev.local')
+      expect(Cypress.env()).to.have.property('api_server', 'http://localhost:8888/v2/')
+    })
+  })
+
+  context('Cypress.Cookies', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/cypress-api/cookies')
+    })
+
+    // https://on.cypress.io/cookies
+    it('Cypress.Cookies.debug() - enable or disable debugging', function () {
+      Cypress.Cookies.debug(true)
+
+      // Cypress will now log in the console when
+      // cookies are set or cleared
+      cy.setCookie('fakeCookie', '123ABC')
+      cy.clearCookie('fakeCookie')
+      cy.setCookie('fakeCookie', '123ABC')
+      cy.clearCookie('fakeCookie')
+      cy.setCookie('fakeCookie', '123ABC')
+    })
+
+    it('Cypress.Cookies.preserveOnce() - preserve cookies by key', function () {
+      // normally cookies are reset after each test
+      cy.getCookie('fakeCookie').should('not.be.ok')
+
+      // preserving a cookie will not clear it when
+      // the next test starts
+      cy.setCookie('lastCookie', '789XYZ')
+      Cypress.Cookies.preserveOnce('lastCookie')
+    })
+
+    it('Cypress.Cookies.defaults() - set defaults for all cookies', function () {
+      // now any cookie with the name 'session_id' will
+      // not be cleared before each new test runs
+      Cypress.Cookies.defaults({
+        whitelist: 'session_id',
+      })
+    })
+  })
+
+  context('Cypress.dom', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/cypress-api/dom')
+    })
+
+    // https://on.cypress.io/dom
+    it('Cypress.dom.isHidden() - determine if a DOM element is hidden', function () {
+      let hiddenP = Cypress.$('.dom-p p.hidden').get(0)
+      let visibleP = Cypress.$('.dom-p p.visible').get(0)
+
+      // our first paragraph has css class 'hidden'
+      expect(Cypress.dom.isHidden(hiddenP)).to.be.true
+      expect(Cypress.dom.isHidden(visibleP)).to.be.false
+    })
+  })
+
+  context('Cypress.Server', function () {
+    beforeEach(function () {
+      cy.visit('https://example.cypress.io/cypress-api/server')
+    })
+
+    // Permanently override server options for
+    // all instances of cy.server()
+
+    // https://on.cypress.io/cypress-server
+    it('Cypress.Server.defaults() - change default config of server', function () {
+      Cypress.Server.defaults({
+        delay: 0,
+        force404: false,
+        whitelist (xhr) {
+          // handle custom logic for whitelisting
+        },
+      })
+    })
+  })
+})

+ 22 - 0
private/cypress/integration/login/Invalid Login.js

@@ -0,0 +1,22 @@
+
+// @flow
+
+import config from '../../config'
+
+declare var cy: any
+
+describe('Coriolis Login', () => {
+  it('Displays incorrect password', () => {
+    cy.server()
+    cy.route({ url: '**/identity/**', method: 'POST' }).as('login')
+
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type('blabla')
+    cy.get('input[label="Password"]').type('blabla')
+
+    cy.get('button').click()
+    cy.wait('@login')
+
+    cy.get('#app').should('contain', 'The username or password did not match.')
+  })
+})

+ 49 - 0
private/cypress/integration/migration/1 - Create Openstack Endpoint.js

@@ -0,0 +1,49 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Create Openstack Endpoint', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Shows new Openstack endpoint dialog', () => {
+    cy.get('div').contains('New').click()
+    cy.get('a').contains('Endpoint').click()
+    cy.get('div[data-test-id="endpointLogo-openstack"]').click()
+  })
+
+  it('Fills Openstack connection info', () => {
+    cy.get('div').contains('Advanced').click()
+    cy.get('input[placeholder="Name"]').type('e2e-openstack-test')
+    cy.get('input[placeholder="Username"]').type(config.endpoints.openstack.username)
+    cy.get('input[placeholder="Password"]').type(config.endpoints.openstack.password)
+    cy.get('input[placeholder="Auth URL"]').type(config.endpoints.openstack.authUrl)
+    cy.get('input[placeholder="Project Name"]').type(config.endpoints.openstack.projectName)
+    cy.get('div[data-test-id="dropdown-glance_api_version"]').first().click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains('2').click()
+    cy.get('div[data-test-id="dropdown-identity_api_version"]').first().click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains('3').click()
+    cy.get('input[placeholder="Project Domain Name"]').type(config.endpoints.openstack.projectDomainName)
+    cy.get('input[placeholder="User Domain Name"]').type(config.endpoints.openstack.userDomainName)
+
+    cy.server()
+    cy.route({ url: '**/actions', method: 'POST' }).as('validate')
+    cy.get('button').contains('Validate and save').click()
+    cy.wait('@validate')
+    cy.get('div[data-test-id="endpointStatus"]').should('contain', 'Endpoint is Valid')
+  })
+
+  it('Added Openstack to endpoint list', () => {
+    cy.visit(`${config.nodeServer}endpoints/`)
+    cy.get('div[data-test-id="endpointListItemContent-e2e-openstack-test"]').should('contain', 'e2e-openstack-test')
+  })
+})

+ 42 - 0
private/cypress/integration/migration/2 - Create VmWare Endpoint.js

@@ -0,0 +1,42 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Create VmWare Endpoint', () => {
+  before(() => {
+    cy.server()
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Shows new VmWare endpoint dialog', () => {
+    cy.get('div').contains('New').click()
+    cy.get('a').contains('Endpoint').click()
+    cy.get('div[data-test-id="endpointLogo-vmware_vsphere"]').click()
+  })
+
+  it('Fills VmWare connection info', () => {
+    cy.get('input[placeholder="Name"]').type('e2e-vmware-test')
+    cy.get('input[placeholder="Username"]').type(config.endpoints.vmware.username)
+    cy.get('input[placeholder="Password"]').type(config.endpoints.vmware.password)
+    cy.get('input[placeholder="Host"]').type(config.endpoints.vmware.host)
+
+    cy.server()
+    cy.route({ url: '**/actions', method: 'POST' }).as('validate')
+    cy.get('button').contains('Validate and save').click()
+    cy.wait('@validate')
+    cy.get('div[data-test-id="endpointStatus"]').should('contain', 'Endpoint is Valid')
+  })
+
+  it('Added Endpoint to endpoint list', () => {
+    cy.visit(`${config.nodeServer}endpoints/`)
+    cy.get('div[data-test-id="endpointListItemContent-e2e-vmware-test"]').should('contain', 'e2e-vmware-test')
+  })
+})

+ 94 - 0
private/cypress/integration/migration/3 - Create VmWare Openstack Migration.js

@@ -0,0 +1,94 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Create VmWare to Openstack Migration', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Shows Wizard page', () => {
+    cy.get('div').contains('New').click()
+    cy.get('a').contains('Migration').click()
+    cy.get('#app').should('contain', 'New Migration')
+  })
+
+  it('Chooses VmWare as Source Cloud', () => {
+    cy.server()
+    cy.route({ url: '**/instances**', method: 'GET' }).as('sourceInstances')
+    cy.get('button').contains('Next').click()
+    cy.get('div[data-test-id="dropdown-vmware_vsphere"]').first().click()
+    cy.get('div').contains('e2e-vmware-test').click()
+    cy.wait('@sourceInstances')
+  })
+
+  it('Chooses Openstack as Target Cloud', () => {
+    cy.get('button').contains('Next').click()
+    cy.get('div[data-test-id="dropdown-openstack"]').first().click()
+    cy.get('div').contains('e2e-openstack-test').click()
+  })
+
+  it('Searches and selects instances', () => {
+    cy.get('button').contains('Next').click()
+    cy.server()
+    cy.route({ url: '**/instances**', method: 'GET' }).as('search')
+    cy.get('input[placeholder="Search VMs"]').type(config.wizard.instancesSearch)
+    cy.wait('@search')
+    cy.get('div[data-test-id="instanceItem"]').contains(config.wizard.instancesSearch)
+    cy.get('div[data-test-id="instanceItem"]').its('length').should('be.gt', 0)
+    cy.get('div[data-test-id="instanceItem"]').eq(config.wizard.instancesSelectItem).click()
+  })
+
+  it('Fills Openstack migration info', () => {
+    cy.get('button').contains('Next').click()
+    cy.get('div').contains('Advanced').click()
+    cy.get('input[placeholder="Floating IP Pool"]').type(config.wizard.openstack.floatingIpPool)
+    cy.get('input[placeholder="Migration Floating IP Pool"]').type(config.wizard.openstack.migrationFloatingIpPool)
+  })
+
+  it('Selects first available network mapping', () => {
+    cy.server()
+    cy.route({ url: '**/networks**', method: 'GET' }).as('networks')
+    cy.route({ url: '**/instances/**', method: 'GET' }).as('instances')
+    cy.get('button').contains('Next').click()
+    cy.wait('@networks')
+    cy.wait('@instances')
+    cy.get('button').contains('Next').should('be.disabled')
+    cy.get('div[data-test-id="networkItem"]').its('length').should('be.gt', 0)
+    cy.get('div[value="Select ..."]').first().click()
+    cy.get('div[data-test-id="dropdownListItem"]').first().click()
+    cy.get('button').contains('Next').should('not.be.disabled')
+  })
+
+  it('Shows summary page', () => {
+    cy.get('button').contains('Next').click()
+    cy.get('#app').should('contain', 'Summary')
+    cy.get('#app').should('contain', 'e2e-vmware-test')
+    cy.get('#app').should('contain', 'e2e-openstack-test')
+    cy.get('#app').should('contain', 'Coriolis Migration')
+    cy.get('#app').should('contain', 'Migration Options')
+    cy.get('#app').should('contain', config.wizard.openstack.migrationFloatingIpPool)
+    cy.get('#app').should('contain', config.wizard.openstack.floatingIpPool)
+    cy.get('#app').should('contain', 'Networks')
+    cy.get('#app').should('contain', 'Instances')
+  })
+
+  it('Executes migration', () => {
+    cy.server()
+    cy.route({ url: '**/migrations', method: 'POST' }).as('migration')
+    cy.get('button').contains('Finish').click()
+    cy.wait('@migration')
+  })
+
+  it('Shows running migration page', () => {
+    cy.get('div[data-test-id="statusPill-RUNNING"]').should('exist')
+  })
+})

+ 29 - 0
private/cypress/integration/migration/4 - Cancel first running migration.js

@@ -0,0 +1,29 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Cancel a running migration', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Cancels migration', () => {
+    cy.server()
+    cy.route({ url: '**/replicas/**', method: 'GET' }).as('replicas')
+    cy.wait('@replicas')
+    cy.get('a').contains('Migrations').click()
+    cy.get('div[data-test-id="statusPill-RUNNING"]').eq(0).click()
+    cy.get('button').contains('Cancel').click()
+    cy.route({ url: '**/actions', method: 'POST' }).as('cancel')
+    cy.get('button').contains('Yes').click()
+    cy.wait('@cancel')
+  })
+})

+ 29 - 0
private/cypress/integration/migration/6 - Delete first migration.js

@@ -0,0 +1,29 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Delete the first migration', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Deletes migration', () => {
+    cy.server()
+    cy.route({ url: '**/replicas/**', method: 'GET' }).as('replicas')
+    cy.wait('@replicas')
+    cy.get('a').contains('Migrations').click()
+    cy.get('div[data-test-id="mainListItem"]').first().click()
+    cy.get('button').last().should('contain', 'Delete Migration').click()
+    cy.route({ url: '**/migrations/**', method: 'DELETE' }).as('delete')
+    cy.get('button').contains('Yes').click()
+    cy.wait('@delete')
+  })
+})

+ 36 - 0
private/cypress/integration/migration/7 - Delete e2e Openstack endpoint.js

@@ -0,0 +1,36 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Delete the Openstack endpoint created for e2e testing', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Goes to endpoints page', () => {
+    cy.get('#app').should('contain', 'Coriolis Replicas')
+    cy.visit(`${config.nodeServer}#/endpoints`)
+    cy.get('#app').should('contain', 'Coriolis Endpoints')
+  })
+
+  it('Delete e2e Openstack endpoint', () => {
+    cy.get('div[data-test-id="endpointListItemContent-e2e-openstack-test"]').should('contain', 'e2e-openstack-test')
+    cy.get('div[data-test-id="endpointListItemContent-e2e-openstack-test"]').first().click()
+    cy.server()
+    cy.route({ url: '**/migrations/**', method: 'GET' }).as('migrations')
+    cy.route({ url: '**/replicas/**', method: 'GET' }).as('replicas')
+    cy.get('button').contains('Delete Endpoint').click()
+    cy.wait('@migrations')
+    cy.wait('@replicas')
+    cy.get('button').contains('Yes').click()
+  })
+})
+

+ 36 - 0
private/cypress/integration/migration/8 - Delete e2e VmWare endpoint.js

@@ -0,0 +1,36 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Delete the VmWare endpoint created for e2e testing', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Goes to endpoints page', () => {
+    cy.get('#app').should('contain', 'Coriolis Replicas')
+    cy.visit(`${config.nodeServer}#/endpoints`)
+    cy.get('#app').should('contain', 'Coriolis Endpoints')
+  })
+
+  it('Delete e2e VmWare endpoint', () => {
+    cy.get('div[data-test-id="endpointListItemContent-e2e-vmware-test"]').should('contain', 'e2e-vmware-test')
+    cy.get('div[data-test-id="endpointListItemContent-e2e-vmware-test"]').first().click()
+    cy.server()
+    cy.route({ url: '**/migrations/**', method: 'GET' }).as('migrations')
+    cy.route({ url: '**/replicas/**', method: 'GET' }).as('replicas')
+    cy.get('button').contains('Delete Endpoint').click()
+    cy.wait('@migrations')
+    cy.wait('@replicas')
+    cy.get('button').contains('Yes').click()
+  })
+})
+

+ 42 - 0
private/cypress/integration/replica/1 - Create Azure Endpoint.js

@@ -0,0 +1,42 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Create Azure Endpoint', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Shows new Azure endpoint dialog', () => {
+    cy.get('div').contains('New').click()
+    cy.get('a').contains('Endpoint').click()
+    cy.get('div[data-test-id="endpointLogo-azure"]').click()
+  })
+
+  it('Fills Azure connection info', () => {
+    cy.get('input[placeholder="Name"]').type('e2e-azure-test')
+    cy.get('div[data-test-id="switch-allow_untrusted"]').click()
+    cy.get('input[placeholder="Username"]').type(config.endpoints.azure.username)
+    cy.get('input[placeholder="Password"]').type(config.endpoints.azure.password)
+    cy.get('input[placeholder="Subscription ID"]').type(config.endpoints.azure.subscriptionId)
+
+    cy.server()
+    cy.route({ url: '**/actions', method: 'POST' }).as('validate')
+    cy.get('button').contains('Validate and save').click()
+    cy.wait('@validate')
+    cy.get('div[data-test-id="endpointStatus"]').should('contain', 'Endpoint is Valid')
+  })
+
+  it('Added Endpoint to endpoint list', () => {
+    cy.visit(`${config.nodeServer}endpoints/`)
+    cy.get('div[data-test-id="endpointListItemContent-e2e-azure-test"]').should('contain', 'e2e-azure-test')
+  })
+})

+ 42 - 0
private/cypress/integration/replica/2 - Create VmWare Endpoint.js

@@ -0,0 +1,42 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Create VmWare Endpoint', () => {
+  before(() => {
+    cy.server()
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Shows new VmWare endpoint dialog', () => {
+    cy.get('div').contains('New').click()
+    cy.get('a').contains('Endpoint').click()
+    cy.get('div[data-test-id="endpointLogo-vmware_vsphere"]').click()
+  })
+
+  it('Fills VmWare connection info', () => {
+    cy.get('input[placeholder="Name"]').type('e2e-vmware-test')
+    cy.get('input[placeholder="Username"]').type(config.endpoints.vmware.username)
+    cy.get('input[placeholder="Password"]').type(config.endpoints.vmware.password)
+    cy.get('input[placeholder="Host"]').type(config.endpoints.vmware.host)
+
+    cy.server()
+    cy.route({ url: '**/actions', method: 'POST' }).as('validate')
+    cy.get('button').contains('Validate and save').click()
+    cy.wait('@validate')
+    cy.get('div[data-test-id="endpointStatus"]').should('contain', 'Endpoint is Valid')
+  })
+
+  it('Added Endpoint to endpoint list', () => {
+    cy.visit(`${config.nodeServer}endpoints/`)
+    cy.get('div[data-test-id="endpointListItemContent-e2e-vmware-test"]').should('contain', 'e2e-vmware-test')
+  })
+})

+ 100 - 0
private/cypress/integration/replica/3 - Create VmWare Azure Replica.js

@@ -0,0 +1,100 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Create VmWare to Azure Replica', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Shows Wizard page', () => {
+    cy.get('div').contains('New').click()
+    cy.get('a').contains('Replica').click()
+    cy.get('#app').should('contain', 'New Replica')
+  })
+
+  it('Chooses VmWare as Source Cloud', () => {
+    cy.server()
+    cy.route({ url: '**/instances**', method: 'GET' }).as('sourceInstances')
+    cy.get('button').contains('Next').click()
+    cy.get('div[data-test-id="dropdown-vmware_vsphere"]').first().click()
+    cy.get('div').contains('e2e-vmware-test').click()
+    cy.wait('@sourceInstances')
+  })
+
+  it('Chooses Azure as Target Cloud', () => {
+    cy.get('button').contains('Next').click()
+    cy.get('div[data-test-id="dropdown-azure"]').first().click()
+    cy.get('div').contains('e2e-azure-test').click()
+  })
+
+  it('Searches and selects instances', () => {
+    cy.get('button').contains('Next').click()
+    cy.server()
+    cy.route({ url: '**/instances**', method: 'GET' }).as('search')
+    cy.get('input[placeholder="Search VMs"]').type(config.wizard.instancesSearch)
+    cy.wait('@search')
+    cy.get('div[data-test-id="instanceItem"]').contains(config.wizard.instancesSearch)
+    cy.get('div[data-test-id="instanceItem"]').its('length').should('be.gt', 0)
+    cy.get('div[data-test-id="instanceItem"]').eq(config.wizard.instancesSelectItem).click()
+  })
+
+  it('Fills Azure replica info', () => {
+    cy.get('button').contains('Next').click()
+    cy.get('div[data-test-id="dropdown-location"]').first().click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains(config.wizard.azure.location.label).click()
+    cy.get('div[data-test-id="dropdown-resource_group"]').first().click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains(config.wizard.azure.resourceGroup.label).click()
+  })
+
+  it('Selects first available network mapping', () => {
+    cy.server()
+    cy.route({ url: '**/networks**', method: 'GET' }).as('networks')
+    cy.route({ url: '**/instances/**', method: 'GET' }).as('instances')
+    cy.get('button').contains('Next').click()
+    cy.wait('@networks')
+    cy.wait('@instances')
+    cy.get('button').contains('Next').should('be.disabled')
+    cy.get('div[data-test-id="networkItem"]').its('length').should('be.gt', 0)
+    cy.get('div[value="Select ..."]').first().click()
+    cy.get('div[data-test-id="dropdownListItem"]').first().click()
+    cy.get('button').contains('Next').should('not.be.disabled')
+  })
+
+  it('Shows schedule page', () => {
+    cy.get('button').contains('Next').click()
+    cy.get('#app').should('contain', 'Schedule')
+  })
+
+  it('Shows summary page', () => {
+    cy.get('button').contains('Next').click()
+    cy.get('#app').should('contain', 'Summary')
+    cy.get('#app').should('contain', 'e2e-vmware-test')
+    cy.get('#app').should('contain', 'e2e-azure-test')
+    cy.get('#app').should('contain', 'Coriolis Replica')
+    cy.get('#app').should('contain', 'Replica Options')
+    cy.get('#app').should('contain', config.wizard.azure.location.value)
+    cy.get('#app').should('contain', config.wizard.azure.resourceGroup.value)
+    cy.get('#app').should('contain', 'Networks')
+    cy.get('#app').should('contain', 'Instances')
+  })
+
+  it('Executes replica', () => {
+    cy.server()
+    cy.route({ url: '**/replicas', method: 'POST' }).as('replica')
+    cy.get('button').contains('Finish').click()
+    cy.wait('@replica')
+  })
+
+  it('Shows running replica page', () => {
+    cy.get('div[data-test-id="statusPill-RUNNING"]').should('exist')
+  })
+})

+ 27 - 0
private/cypress/integration/replica/4 - Cancel first running replica.js

@@ -0,0 +1,27 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Cancel a running replica', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Cancels replica execution', () => {
+    cy.get('div[data-test-id="statusPill-RUNNING"]').eq(0).click()
+    cy.get('a').contains('Executions').click()
+    cy.server()
+    cy.get('button').contains('Cancel Execution').click()
+    cy.route({ url: '**/actions', method: 'POST' }).as('cancel')
+    cy.get('button').contains('Yes').click()
+    cy.wait('@cancel')
+  })
+})

+ 24 - 0
private/cypress/integration/replica/5 - Cannot delete used endpoint.js

@@ -0,0 +1,24 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Cannot delete used endpoint', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Should show in usage message when trying to delete', () => {
+    cy.get('div').contains('Cloud Endpoints').first().click()
+    cy.get('div[data-test-id="endpointListItemContent-e2e-azure-test"]').click()
+    cy.get('button').contains('Delete Endpoint').click()
+    cy.get('div[data-test-id="alertModal"]').should('contain', 'The endpoint can\'t be deleted because it is in use by replicas or migrations.')
+  })
+})

+ 28 - 0
private/cypress/integration/replica/6 - Delete first replica.js

@@ -0,0 +1,28 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Delete the first replica', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Delete replica', () => {
+    cy.server()
+    cy.route({ url: '**/executions/**', method: 'GET' }).as('executions')
+    cy.get('div[data-test-id="mainListItem"]').first().click()
+    cy.wait('@executions')
+    cy.get('button').last().should('contain', 'Delete Replica').click()
+    cy.route({ url: '**/replicas/**', method: 'DELETE' }).as('delete')
+    cy.get('button').contains('Yes').click()
+    cy.wait('@delete')
+  })
+})

+ 36 - 0
private/cypress/integration/replica/7 - Delete e2e Azure endpoint.js

@@ -0,0 +1,36 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Delete the Azure endpoint created for e2e testing', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Goes to endpoints page', () => {
+    cy.get('#app').should('contain', 'Coriolis Replicas')
+    cy.visit(`${config.nodeServer}#/endpoints`)
+    cy.get('#app').should('contain', 'Coriolis Endpoints')
+  })
+
+  it('Delete e2e Azure endpoint', () => {
+    cy.get('div[data-test-id="endpointListItemContent-e2e-azure-test"]').should('contain', 'e2e-azure-test')
+    cy.get('div[data-test-id="endpointListItemContent-e2e-azure-test"]').first().click()
+    cy.server()
+    cy.route({ url: '**/migrations/**', method: 'GET' }).as('migrations')
+    cy.route({ url: '**/replicas/**', method: 'GET' }).as('replicas')
+    cy.get('button').contains('Delete Endpoint').click()
+    cy.wait('@migrations')
+    cy.wait('@replicas')
+    cy.get('button').contains('Yes').click()
+  })
+})
+

+ 36 - 0
private/cypress/integration/replica/8 - Delete e2e VmWare endpoint.js

@@ -0,0 +1,36 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Delete the VmWare endpoint created for e2e testing', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Goes to endpoints page', () => {
+    cy.get('#app').should('contain', 'Coriolis Replicas')
+    cy.visit(`${config.nodeServer}#/endpoints`)
+    cy.get('#app').should('contain', 'Coriolis Endpoints')
+  })
+
+  it('Delete e2e VmWare endpoint', () => {
+    cy.get('div[data-test-id="endpointListItemContent-e2e-vmware-test"]').should('contain', 'e2e-vmware-test')
+    cy.get('div[data-test-id="endpointListItemContent-e2e-vmware-test"]').first().click()
+    cy.server()
+    cy.route({ url: '**/migrations/**', method: 'GET' }).as('migrations')
+    cy.route({ url: '**/replicas/**', method: 'GET' }).as('replicas')
+    cy.get('button').contains('Delete Endpoint').click()
+    cy.wait('@migrations')
+    cy.wait('@replicas')
+    cy.get('button').contains('Yes').click()
+  })
+})
+

+ 71 - 0
private/cypress/integration/scheduler/Scheduler Operations.js

@@ -0,0 +1,71 @@
+
+// @flow
+
+import config from '../../config'
+
+describe('Create Azure Endpoint', () => {
+  before(() => {
+    cy.visit(config.nodeServer)
+    cy.get('input[label="Username"]').type(config.username)
+    cy.get('input[label="Password"]').type(config.password)
+    cy.get('button').click()
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('unscopedToken', 'token', 'projectId')
+  })
+
+  it('Goes to scheduler\'s page', () => {
+    cy.get('div[data-test-id="mainListItem"]').first().click()
+    cy.get('a').contains('Schedule').click()
+    cy.get('button').should('contain', 'Add Schedule')
+  })
+
+  it('Creates a schedule', () => {
+    cy.server()
+    cy.route('POST', '**/schedules').as('schedule')
+    cy.get('button').contains('Add Schedule').click()
+    cy.wait('@schedule')
+    cy.get('div[data-test-id="saveButton"]').should('not.be.visible')
+  })
+
+  it('Changes the month', () => {
+    cy.get('div[data-test-id="monthDropdown"]').last().click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains('October').click()
+    cy.get('div[data-test-id="monthDropdown"]').last().should('contain', 'October')
+    cy.get('div[data-test-id="saveButton"]').should('be.visible')
+  })
+
+  it('Changes the hour', () => {
+    cy.get('div[data-test-id="hourDropdown"]').last().click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains('04').click()
+    cy.get('div[data-test-id="hourDropdown"]').last().should('contain', '04')
+  })
+
+  it('Changes timezone', () => {
+    cy.get('div').contains('Local Time').click()
+    cy.get('div').contains('UTC').click()
+    let utcTime = 4 + (new Date().getTimezoneOffset() / 60)
+    if (utcTime < 10) {
+      utcTime = `0${utcTime}`
+    }
+    utcTime = utcTime.toString()
+    cy.get('div[data-test-id="hourDropdown"]').last().should('contain', utcTime)
+  })
+
+  it('Saves the changes', () => {
+    cy.server()
+    cy.route('PUT', '**/schedules/**').as('schedule')
+    cy.get('div[data-test-id="saveButton"]').should('be.visible').last().click()
+    cy.wait('@schedule')
+    cy.get('div[data-test-id="saveButton"]').should('not.be.visible')
+  })
+
+  it('Deletes the last schedule', () => {
+    cy.get('div[data-test-id="deleteButton"]').last().click()
+    cy.server()
+    cy.route('DELETE', '**/schedules/**').as('schedule')
+    cy.get('button').contains('Yes').click()
+    cy.wait('@schedule')
+  })
+})

+ 2 - 2
src/components/atoms/DropdownButton/index.jsx

@@ -128,8 +128,8 @@ const DropdownButton = (props: Props) => {
       disabled={props.disabled}
       {...props}
     >
-      <Label {...props} disabled={props.disabled}>{props.value}</Label>
-      <Arrow {...props} disabled={props.disabled} dangerouslySetInnerHTML={{ __html: arrowImage }} />
+      <Label {...props} data-test-id="" disabled={props.disabled}>{props.value}</Label>
+      <Arrow {...props} data-test-id="" disabled={props.disabled} dangerouslySetInnerHTML={{ __html: arrowImage }} />
     </Wrapper>
   )
 }

+ 1 - 0
src/components/atoms/StatusPill/index.jsx

@@ -118,6 +118,7 @@ class StatusPill extends React.Component<Props> {
         secondary={this.props.secondary}
         alert={this.props.alert}
         small={this.props.small}
+        data-test-id={`statusPill-${this.props.status || 'null'}`}
       >
         {this.props.label || this.props.status}
       </Wrapper>

+ 2 - 0
src/components/atoms/Switch/index.jsx

@@ -126,6 +126,7 @@ type Props = {
   big: boolean,
   checkedLabel: string,
   uncheckedLabel: string,
+  dataTestId?: string,
   style: {[string]: mixed},
 }
 type State = {
@@ -182,6 +183,7 @@ class Switch extends React.Component<Props, State> {
         secondary={this.props.secondary}
         disabled={this.props.disabled}
         onClick={() => { this.handleInputChange() }}
+        data-test-id={this.props.dataTestId}
       >
         <InputBackground
           triState={this.props.triState}

+ 5 - 4
src/components/molecules/Dropdown/index.jsx

@@ -24,9 +24,6 @@ import DropdownButton from '../../atoms/DropdownButton'
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 
-const Wrapper = styled.div`
-  position: relative;
-`
 const getWidth = props => {
   if (props.large) {
     return StyleProps.inputSizes.large.width - 2
@@ -38,6 +35,9 @@ const getWidth = props => {
 
   return StyleProps.inputSizes.regular.width - 2
 }
+const Wrapper = styled.div`
+  position: relative;
+`
 const List = styled.div`
   position: absolute;
   background: white;
@@ -70,8 +70,8 @@ const ListItem = styled.div`
   position: relative;
   color: ${Palette.grayscale[4]};
   padding: 8px 16px;
-  transition: all ${StyleProps.animations.swift};
   ${props => props.selected ? `font-weight: ${StyleProps.fontWeights.medium};` : ''}
+  transition: all ${StyleProps.animations.swift};
 
   &:first-child {
     border-top-left-radius: ${StyleProps.borderRadius};
@@ -263,6 +263,7 @@ class Dropdown extends React.Component<Props, State> {
             let duplicatedLabel = duplicatedLabels.find(l => l === label)
             let listItem = (
               <ListItem
+                data-test-id="dropdownListItem"
                 key={value}
                 onMouseDown={() => { this.itemMouseDown = true }}
                 onMouseUp={() => { this.itemMouseDown = false }}

+ 2 - 0
src/components/molecules/EndpointField/index.jsx

@@ -60,6 +60,7 @@ class Field extends React.Component<Props> {
   renderSwitch() {
     return (
       <Switch
+        dataTestId={`switch-${this.props.name}`}
         disabled={this.props.disabled}
         checked={this.props.value || false}
         onChange={checked => { this.props.onChange(checked) }}
@@ -114,6 +115,7 @@ class Field extends React.Component<Props> {
 
     return (
       <Dropdown
+        data-test-id={`dropdown-${this.props.name}`}
         large={this.props.large}
         selectedItem={this.props.value}
         items={items}

+ 1 - 1
src/components/molecules/EndpointListItem/index.jsx

@@ -109,7 +109,7 @@ class EndpointListItem extends React.Component<Props> {
           checked={this.props.selected}
           onChange={this.props.onSelectedChange}
         />
-        <Content onClick={this.props.onClick}>
+        <Content onClick={this.props.onClick} data-test-id={`endpointListItemContent-${this.props.item.name}`}>
           <Image image={endpointImage} />
           <Title>
             <TitleLabel>{this.props.item.name}</TitleLabel>

+ 1 - 1
src/components/molecules/MainListItem/index.jsx

@@ -199,7 +199,7 @@ class MainListItem extends React.Component<Props> {
           checked={this.props.selected}
           onChange={this.props.onSelectedChange}
         />
-        <Content onClick={this.props.onClick}>
+        <Content onClick={this.props.onClick} data-test-id="mainListItem">
           <Image image={this.props.image} />
           <Title>
             <TitleLabel>{this.props.item.instances[0]}</TitleLabel>

+ 5 - 1
src/components/molecules/ScheduleItem/index.jsx

@@ -207,6 +207,7 @@ class ScheduleItem extends React.Component<Props> {
         useBold={this.shouldUseBold('month')}
         selectedItem={this.getFieldValue(items, 'month')}
         onChange={item => { this.handleMonthChange(item) }}
+        data-test-id="monthDropdown"
       />
     )
   }
@@ -276,6 +277,7 @@ class ScheduleItem extends React.Component<Props> {
         useBold={this.shouldUseBold('hour')}
         selectedItem={this.getFieldValue(items, 'hour', true, 1)}
         onChange={item => { this.handleHourChange(item.value) }}
+        data-test-id="hourDropdown"
       />
     )
   }
@@ -327,7 +329,7 @@ class ScheduleItem extends React.Component<Props> {
   render() {
     let enabled = typeof this.props.item.enabled !== 'undefined' && this.props.item.enabled !== null ? this.props.item.enabled : false
     return (
-      <Wrapper>
+      <Wrapper data-test-id="scheduleItem">
         <Data width={this.props.colWidths[0]}>
           <Switch
             noLabel
@@ -368,10 +370,12 @@ class ScheduleItem extends React.Component<Props> {
           >•••</Button>
         </Data>
         <DeleteButton
+          data-test-id="deleteButton"
           onClick={this.props.onDeleteClick}
           hidden={this.props.item.enabled}
         />
         <SaveButton
+          data-test-id="saveButton"
           onClick={this.props.onSaveSchedule}
           hidden={this.props.item.enabled || !this.props.unsavedSchedules.find(us => us.id === this.props.item.id)}
         />

+ 1 - 0
src/components/molecules/WizardOptionsField/index.jsx

@@ -119,6 +119,7 @@ class WizardOptionsField extends React.Component<Props> {
     return (
       <Dropdown
         width={320}
+        data-test-id={`dropdown-${this.props.name}`}
         noSelectionMessage="Choose a value"
         selectedItem={selectedItem}
         items={items}

+ 1 - 1
src/components/organisms/AlertModal/index.jsx

@@ -107,7 +107,7 @@ class AlertModal extends React.Component<Props> {
   render() {
     return (
       <Modal {...this.props}>
-        <Wrapper>
+        <Wrapper data-test-id="alertModal">
           {this.props.type === 'loading' ? <StatusImage loading /> : <Image type={this.props.type} />}
           {this.props.message ? <Message>{this.props.message}</Message> : null}
           {this.props.extraMessage ? <ExtraMessage>{this.props.extraMessage}</ExtraMessage> : null}

+ 1 - 0
src/components/organisms/ChooseProvider/index.jsx

@@ -89,6 +89,7 @@ class ChooseProvider extends React.Component<Props> {
                 height={128}
                 key={k}
                 endpoint={k}
+                data-test-id={`endpointLogo-${k}`}
                 onClick={() => { this.props.onProviderClick(k) }}
               />
             )

+ 2 - 4
src/components/organisms/DetailsContentHeader/index.jsx

@@ -126,11 +126,9 @@ class DetailsContentHeader extends React.Component<Props> {
           primary={this.props.primaryInfoPill}
         />
         <StatusPill
+          data-test-id={`statusPill-${statusLabel || ''}`}
           status={this.getStatus()}
-          label={
-            // $FlowIssue
-            statusLabel
-          }
+          label={statusLabel || ''}
         />
       </StatusPills>
     )

+ 1 - 1
src/components/organisms/Endpoint/index.jsx

@@ -331,7 +331,7 @@ class Endpoint extends React.Component<Props, State> {
     }
 
     return (
-      <Status>
+      <Status data-test-id="endpointStatus">
         <StatusHeader>
           <StatusIcon status={status} />
           <StatusMessage>{message}{showErrorButton}</StatusMessage>

+ 1 - 0
src/components/organisms/WizardEndpointList/index.jsx

@@ -96,6 +96,7 @@ class WizardEndpointList extends React.Component<Props> {
 
       actionInput = (
         <Dropdown
+          data-test-id={`dropdown-${provider}`}
           primary={Boolean(selectedItem)}
           items={items}
           valueField="id"

+ 1 - 1
src/components/organisms/WizardInstances/index.jsx

@@ -296,7 +296,7 @@ class WizardInstances extends React.Component<Props, State> {
               selected={selected}
             >
               <CheckboxStyled checked={selected} onChange={() => {}} />
-              <InstanceContent>
+              <InstanceContent data-test-id="instanceItem">
                 <Image />
                 <Label>{instance.instance_name}</Label>
                 <Details>{`${instance.num_cpu} vCPU | ${instance.memory_mb} MB RAM${flavorName}`}</Details>

+ 1 - 1
src/components/organisms/WizardNetworks/index.jsx

@@ -177,7 +177,7 @@ class WizardNetworks extends React.Component<Props> {
           }).map(i => i.instance_name)
           let selectedNetwork = this.props.selectedNetworks && this.props.selectedNetworks.find(n => n.sourceNic.network_name === nic.network_name)
           return (
-            <Nic key={nic.id}>
+            <Nic key={nic.id} data-test-id="networkItem">
               <NetworkImage />
               <NetworkTitle>
                 <NetworkName>{nic.network_name}</NetworkName>

+ 429 - 22
yarn.lock

@@ -2,6 +2,21 @@
 # yarn lockfile v1
 
 
+"@cypress/listr-verbose-renderer@0.4.1":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#a77492f4b11dcc7c446a34b3e28721afd33c642a"
+  dependencies:
+    chalk "^1.1.3"
+    cli-cursor "^1.0.2"
+    date-fns "^1.27.2"
+    figures "^1.7.0"
+
+"@cypress/xvfb@1.1.3":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@cypress/xvfb/-/xvfb-1.1.3.tgz#6294a7d1feb751f12302248f2089fc534c4acb7f"
+  dependencies:
+    lodash.once "^4.1.1"
+
 "@hypnosphi/fuse.js@^3.0.9":
   version "3.0.9"
   resolved "https://registry.yarnpkg.com/@hypnosphi/fuse.js/-/fuse.js-3.0.9.tgz#ea99f6121b4a8f065b4c71f85595db2714498807"
@@ -140,6 +155,29 @@
     react-treebeard "^2.0.3"
     redux "^3.7.2"
 
+"@types/blob-util@1.3.3":
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/@types/blob-util/-/blob-util-1.3.3.tgz#adba644ae34f88e1dd9a5864c66ad651caaf628a"
+
+"@types/bluebird@3.5.18":
+  version "3.5.18"
+  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.18.tgz#6a60435d4663e290f3709898a4f75014f279c4d6"
+
+"@types/chai-jquery@1.1.35":
+  version "1.1.35"
+  resolved "https://registry.yarnpkg.com/@types/chai-jquery/-/chai-jquery-1.1.35.tgz#9a8f0a39ec0851b2768a8f8c764158c2a2568d04"
+  dependencies:
+    "@types/chai" "*"
+    "@types/jquery" "*"
+
+"@types/chai@*":
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.2.tgz#f1af664769cfb50af805431c407425ed619daa21"
+
+"@types/chai@4.0.8":
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.0.8.tgz#d27600e9ba2f371e08695d90a0fe0408d89c7be7"
+
 "@types/form-data@*":
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e"
@@ -150,6 +188,26 @@
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@types/inline-style-prefixer/-/inline-style-prefixer-3.0.1.tgz#8541e636b029124b747952e9a28848286d2b5bf6"
 
+"@types/jquery@*":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.1.tgz#55758d44d422756d6329cbf54e6d41931d7ba28f"
+
+"@types/jquery@3.2.16":
+  version "3.2.16"
+  resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.2.16.tgz#04419c404a3194350e7d3f339a90e72c88db3111"
+
+"@types/lodash@4.14.87":
+  version "4.14.87"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.87.tgz#55f92183b048c2c64402afe472f8333f4e319a6b"
+
+"@types/minimatch@3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.1.tgz#b683eb60be358304ef146f5775db4c0e3696a550"
+
+"@types/mocha@2.2.44":
+  version "2.2.44"
+  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-2.2.44.tgz#1d4a798e53f35212fd5ad4d04050620171cd5b5e"
+
 "@types/node@*", "@types/node@^8.0.25", "@types/node@^8.0.47", "@types/node@^8.0.53":
   version "8.5.1"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-8.5.1.tgz#4ec3020bcdfe2abffeef9ba3fbf26fca097514b5"
@@ -169,6 +227,21 @@
     "@types/form-data" "*"
     "@types/node" "*"
 
+"@types/sinon-chai@2.7.29":
+  version "2.7.29"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-2.7.29.tgz#4db01497e2dd1908b2bd30d1782f456353f5f723"
+  dependencies:
+    "@types/chai" "*"
+    "@types/sinon" "*"
+
+"@types/sinon@*":
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-4.3.0.tgz#7f53915994a00ccea24f4e0c24709822ed11a3b1"
+
+"@types/sinon@4.0.0":
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-4.0.0.tgz#9a93ffa4ee1329e85166278a5ed99f81dc4c8362"
+
 "@types/uuid@^3.4.2", "@types/uuid@^3.4.3":
   version "3.4.3"
   resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.3.tgz#121ace265f5569ce40f4f6d0ff78a338c732a754"
@@ -339,6 +412,10 @@ amdefine@>=0.0.4:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
 
+ansi-escapes@^1.0.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
+
 ansi-escapes@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92"
@@ -498,6 +575,12 @@ async@1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/async/-/async-1.5.0.tgz#2796642723573859565633fc6274444bee2f8ce3"
 
+async@2.1.4:
+  version "2.1.4"
+  resolved "https://registry.yarnpkg.com/async/-/async-2.1.4.tgz#2d2160c7788032e4dd6cbe2502f1f9a2c8f6cde4"
+  dependencies:
+    lodash "^4.14.0"
+
 async@2.5.0, async@^2.1.2:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d"
@@ -1663,6 +1746,10 @@ block-stream@*:
   dependencies:
     inherits "~2.0.0"
 
+bluebird@3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c"
+
 bluebird@^3.4.7:
   version "3.5.1"
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
@@ -1820,6 +1907,10 @@ bser@^2.0.0:
   dependencies:
     node-int64 "^0.4.0"
 
+buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+
 buffer-equal-constant-time@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
@@ -1938,7 +2029,15 @@ chainsaw@~0.1.0:
   dependencies:
     traverse ">=0.3.0 <0.4"
 
-chalk@^1.1.3:
+chalk@2.1.0, chalk@^2.0.0, chalk@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.1.0.tgz#ac5becf14fa21b99c6c92ca7a7d7cfd5b17e743e"
+  dependencies:
+    ansi-styles "^3.1.0"
+    escape-string-regexp "^1.0.5"
+    supports-color "^4.0.0"
+
+chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
   dependencies:
@@ -1948,14 +2047,6 @@ chalk@^1.1.3:
     strip-ansi "^3.0.0"
     supports-color "^2.0.0"
 
-chalk@^2.0.0, chalk@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.1.0.tgz#ac5becf14fa21b99c6c92ca7a7d7cfd5b17e743e"
-  dependencies:
-    ansi-styles "^3.1.0"
-    escape-string-regexp "^1.0.5"
-    supports-color "^4.0.0"
-
 chalk@^2.0.1, chalk@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba"
@@ -1968,6 +2059,10 @@ charenc@~0.0.1:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
 
+check-more-types@2.24.0:
+  version "2.24.0"
+  resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600"
+
 cheerio@^1.0.0-rc.2:
   version "1.0.0-rc.2"
   resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
@@ -2025,12 +2120,29 @@ clean-css@4.1.x:
   dependencies:
     source-map "0.5.x"
 
+cli-cursor@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
+  dependencies:
+    restore-cursor "^1.0.1"
+
 cli-cursor@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
   dependencies:
     restore-cursor "^2.0.0"
 
+cli-spinners@^0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-0.1.2.tgz#bb764d88e185fb9e1e6a2a1f19772318f605e31c"
+
+cli-truncate@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574"
+  dependencies:
+    slice-ansi "0.0.4"
+    string-width "^1.0.1"
+
 cli-width@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
@@ -2115,15 +2227,15 @@ combined-stream@^1.0.5, combined-stream@~1.0.5:
   dependencies:
     delayed-stream "~1.0.0"
 
+commander@2.11.0, commander@^2.11.0, commander@^2.9.0:
+  version "2.11.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563"
+
 commander@2.12.x, commander@~2.12.1:
   version "2.12.2"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555"
 
-commander@^2.11.0, commander@^2.9.0:
-  version "2.11.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563"
-
-common-tags@^1.4.0:
+common-tags@1.4.0, common-tags@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.4.0.tgz#1187be4f3d4cf0c0427d43f74eef1f73501614c0"
   dependencies:
@@ -2137,7 +2249,7 @@ concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
 
-concat-stream@^1.6.0:
+concat-stream@1.6.0, concat-stream@^1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7"
   dependencies:
@@ -2463,6 +2575,47 @@ cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0":
   dependencies:
     cssom "0.3.x"
 
+cypress@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/cypress/-/cypress-2.1.0.tgz#a8bd7d9b89c38a1e380db83b57d9bba0dbb95ba4"
+  dependencies:
+    "@cypress/listr-verbose-renderer" "0.4.1"
+    "@cypress/xvfb" "1.1.3"
+    "@types/blob-util" "1.3.3"
+    "@types/bluebird" "3.5.18"
+    "@types/chai" "4.0.8"
+    "@types/chai-jquery" "1.1.35"
+    "@types/jquery" "3.2.16"
+    "@types/lodash" "4.14.87"
+    "@types/minimatch" "3.0.1"
+    "@types/mocha" "2.2.44"
+    "@types/sinon" "4.0.0"
+    "@types/sinon-chai" "2.7.29"
+    bluebird "3.5.0"
+    chalk "2.1.0"
+    check-more-types "2.24.0"
+    commander "2.11.0"
+    common-tags "1.4.0"
+    debug "3.1.0"
+    extract-zip "1.6.6"
+    fs-extra "4.0.1"
+    getos "2.8.4"
+    glob "7.1.2"
+    is-ci "1.0.10"
+    is-installed-globally "0.1.0"
+    lazy-ass "1.6.0"
+    listr "0.12.0"
+    lodash "4.17.4"
+    minimist "1.2.0"
+    progress "1.1.8"
+    ramda "0.24.1"
+    request "2.81.0"
+    request-progress "0.3.1"
+    supports-color "5.1.0"
+    tmp "0.0.31"
+    url "0.11.0"
+    yauzl "2.8.0"
+
 d@1:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f"
@@ -2479,6 +2632,10 @@ dashdash@^1.12.0:
   dependencies:
     assert-plus "^1.0.0"
 
+date-fns@^1.27.2:
+  version "1.29.0"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.29.0.tgz#12e609cdcb935127311d04d33334e2960a2a54e6"
+
 date-now@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
@@ -2493,7 +2650,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.6.8:
   dependencies:
     ms "2.0.0"
 
-debug@^3.0.1, debug@^3.1.0:
+debug@3.1.0, debug@^3.0.1, debug@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   dependencies:
@@ -2717,6 +2874,10 @@ electron-to-chromium@^1.3.27:
   version "1.3.27"
   resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.27.tgz#78ecb8a399066187bb374eede35d9c70565a803d"
 
+elegant-spinner@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e"
+
 elliptic@^6.0.0:
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df"
@@ -2944,6 +3105,12 @@ eslint-module-utils@^2.1.1:
     debug "^2.6.8"
     pkg-dir "^1.0.0"
 
+eslint-plugin-cypress@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-2.0.1.tgz#647e942cacbfd71b0f1a1ed6978472fbd475c60a"
+  dependencies:
+    globals "^11.0.1"
+
 eslint-plugin-flowtype@^2.46.1:
   version "2.46.1"
   resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-2.46.1.tgz#c4f81d580cd89c82bc3a85a1ccf4ae3a915143a4"
@@ -3123,6 +3290,10 @@ exenv@^1.2.0, exenv@^1.2.1:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
 
+exit-hook@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
+
 expand-brackets@^0.1.4:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
@@ -3234,6 +3405,15 @@ extglob@^0.3.1:
   dependencies:
     is-extglob "^1.0.0"
 
+extract-zip@1.6.6:
+  version "1.6.6"
+  resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.6.6.tgz#1290ede8d20d0872b429fd3f351ca128ec5ef85c"
+  dependencies:
+    concat-stream "1.6.0"
+    debug "2.6.9"
+    mkdirp "0.5.0"
+    yauzl "2.4.1"
+
 extsprintf@1.3.0, extsprintf@^1.2.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
@@ -3276,6 +3456,19 @@ fbjs@^0.8.12, fbjs@^0.8.16, fbjs@^0.8.5, fbjs@^0.8.9:
     setimmediate "^1.0.5"
     ua-parser-js "^0.7.9"
 
+fd-slicer@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.0.1.tgz#8b5bcbd9ec327c5041bf9ab023fd6750f1177e65"
+  dependencies:
+    pend "~1.2.0"
+
+figures@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
+  dependencies:
+    escape-string-regexp "^1.0.5"
+    object-assign "^4.1.0"
+
 figures@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
@@ -3454,6 +3647,14 @@ fresh@0.5.2:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
 
+fs-extra@4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.1.tgz#7fc0c6c8957f983f57f306a24e5b9ddd8d0dd880"
+  dependencies:
+    graceful-fs "^4.1.2"
+    jsonfile "^3.0.0"
+    universalify "^0.1.0"
+
 fs-extra@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-5.0.0.tgz#414d0110cdd06705734d055652c5411260c31abd"
@@ -3551,6 +3752,12 @@ get-stream@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
 
+getos@2.8.4:
+  version "2.8.4"
+  resolved "https://registry.yarnpkg.com/getos/-/getos-2.8.4.tgz#7b8603d3619c28e38cb0fe7a4f63c3acb80d5163"
+  dependencies:
+    async "2.1.4"
+
 getpass@^0.1.1:
   version "0.1.7"
   resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
@@ -3598,7 +3805,7 @@ glob-parent@^2.0.0:
   dependencies:
     is-glob "^2.0.0"
 
-glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2:
+glob@7.1.2, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2:
   version "7.1.2"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
   dependencies:
@@ -3609,6 +3816,12 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+global-dirs@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
+  dependencies:
+    ini "^1.3.4"
+
 global@^4.3.0, global@^4.3.2:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f"
@@ -3620,6 +3833,10 @@ globals@^10.0.0:
   version "10.1.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-10.1.0.tgz#4425a1881be0d336b4a823a82a7be725d5dd987c"
 
+globals@^11.0.1:
+  version "11.3.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-11.3.0.tgz#e04fdb7b9796d8adac9c8f64c14837b2313378b0"
+
 globals@^9.17.0, globals@^9.18.0:
   version "9.18.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
@@ -3964,6 +4181,16 @@ imurmurhash@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
 
+indent-string@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
+  dependencies:
+    repeating "^2.0.0"
+
+indent-string@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289"
+
 indexes-of@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
@@ -3987,6 +4214,10 @@ inherits@2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1"
 
+ini@^1.3.4:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
+
 ini@~1.3.0:
   version "1.3.4"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e"
@@ -4074,7 +4305,7 @@ is-callable@^1.1.1, is-callable@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
 
-is-ci@^1.0.10:
+is-ci@1.0.10, is-ci@^1.0.10:
   version "1.0.10"
   resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.0.10.tgz#f739336b2632365061a9d48270cd56ae3369318e"
   dependencies:
@@ -4136,6 +4367,13 @@ is-glob@^2.0.0, is-glob@^2.0.1:
   dependencies:
     is-extglob "^1.0.0"
 
+is-installed-globally@0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
+  dependencies:
+    global-dirs "^0.1.0"
+    is-path-inside "^1.0.0"
+
 is-number@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
@@ -4672,6 +4910,12 @@ json5@^0.5.0, json5@^0.5.1:
   version "0.5.1"
   resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821"
 
+jsonfile@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-3.0.1.tgz#a5ecc6f65f53f662c4415c7675a0331d0992ec66"
+  optionalDependencies:
+    graceful-fs "^4.1.6"
+
 jsonfile@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
@@ -4738,6 +4982,10 @@ kind-of@^4.0.0:
   dependencies:
     is-buffer "^1.1.5"
 
+lazy-ass@1.6.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513"
+
 lazy-cache@^1.0.3:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
@@ -4759,6 +5007,53 @@ levn@^0.3.0, levn@~0.3.0:
     prelude-ls "~1.1.2"
     type-check "~0.3.2"
 
+listr-silent-renderer@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e"
+
+listr-update-renderer@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.2.0.tgz#ca80e1779b4e70266807e8eed1ad6abe398550f9"
+  dependencies:
+    chalk "^1.1.3"
+    cli-truncate "^0.2.1"
+    elegant-spinner "^1.0.1"
+    figures "^1.7.0"
+    indent-string "^3.0.0"
+    log-symbols "^1.0.2"
+    log-update "^1.0.2"
+    strip-ansi "^3.0.1"
+
+listr-verbose-renderer@^0.4.0:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#8206f4cf6d52ddc5827e5fd14989e0e965933a35"
+  dependencies:
+    chalk "^1.1.3"
+    cli-cursor "^1.0.2"
+    date-fns "^1.27.2"
+    figures "^1.7.0"
+
+listr@0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/listr/-/listr-0.12.0.tgz#6bce2c0f5603fa49580ea17cd6a00cc0e5fa451a"
+  dependencies:
+    chalk "^1.1.3"
+    cli-truncate "^0.2.1"
+    figures "^1.7.0"
+    indent-string "^2.1.0"
+    is-promise "^2.1.0"
+    is-stream "^1.1.0"
+    listr-silent-renderer "^1.1.1"
+    listr-update-renderer "^0.2.0"
+    listr-verbose-renderer "^0.4.0"
+    log-symbols "^1.0.2"
+    log-update "^1.0.2"
+    ora "^0.2.3"
+    p-map "^1.1.1"
+    rxjs "^5.0.0-beta.11"
+    stream-to-observable "^0.1.0"
+    strip-ansi "^3.0.1"
+
 load-json-file@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -4894,6 +5189,10 @@ lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
 
+lodash.once@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
+
 lodash.pick@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"
@@ -4916,7 +5215,7 @@ lodash.words@^3.0.0:
   dependencies:
     lodash._root "^3.0.0"
 
-lodash@4.x.x, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.6.1:
+lodash@4.17.4, lodash@4.x.x, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.6.1:
   version "4.17.4"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
 
@@ -4924,6 +5223,19 @@ lodash@^3.10.1:
   version "3.10.1"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
 
+log-symbols@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
+  dependencies:
+    chalk "^1.0.0"
+
+log-update@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/log-update/-/log-update-1.0.2.tgz#19929f64c4093d2d2e7075a1dad8af59c296b8d1"
+  dependencies:
+    ansi-escapes "^1.0.0"
+    cli-cursor "^1.0.2"
+
 lolex@^1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6"
@@ -5119,7 +5431,7 @@ minimist@0.0.8:
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
 
-minimist@^1.1.1, minimist@^1.2.0:
+minimist@1.2.0, minimist@^1.1.1, minimist@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
 
@@ -5133,6 +5445,12 @@ mkdirp@0.5, mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp
   dependencies:
     minimist "0.0.8"
 
+mkdirp@0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.0.tgz#1d73076a6df986cd9344e15e71fcc05a4c9abf12"
+  dependencies:
+    minimist "0.0.8"
+
 mobx-react@^4.4.2:
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-4.4.2.tgz#22d710974883de1763cdafce3025570a79c64336"
@@ -5472,6 +5790,10 @@ once@^1.3.0, once@^1.3.3, once@^1.4.0:
   dependencies:
     wrappy "1"
 
+onetime@^1.0.0:
+  version "1.1.0"
+  resolved "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
+
 onetime@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
@@ -5496,6 +5818,15 @@ optionator@^0.8.1, optionator@^0.8.2:
     type-check "~0.3.2"
     wordwrap "~1.0.0"
 
+ora@^0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/ora/-/ora-0.2.3.tgz#37527d220adcd53c39b73571d754156d5db657a4"
+  dependencies:
+    chalk "^1.1.1"
+    cli-cursor "^1.0.2"
+    cli-spinners "^0.1.2"
+    object-assign "^4.0.1"
+
 os-browserify@^0.2.0:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f"
@@ -5518,7 +5849,7 @@ os-locale@^2.0.0:
     lcid "^1.0.0"
     mem "^1.1.0"
 
-os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
 
@@ -5551,6 +5882,10 @@ p-locate@^2.0.0:
   dependencies:
     p-limit "^1.1.0"
 
+p-map@^1.1.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
+
 p-timeout@^1.1.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-1.2.1.tgz#5eb3b353b7fce99f101a1038880bb054ebbea386"
@@ -5677,6 +6012,10 @@ pbkdf2@^3.0.3:
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
 
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+
 performance-now@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"
@@ -6064,6 +6403,10 @@ process@~0.5.1:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf"
 
+progress@1.1.8:
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be"
+
 progress@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f"
@@ -6178,6 +6521,10 @@ railroad-diagrams@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e"
 
+ramda@0.24.1:
+  version "0.24.1"
+  resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.24.1.tgz#c3b7755197f35b8dc3502228262c4c91ddb6b857"
+
 randexp@^0.4.2:
   version "0.4.6"
   resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3"
@@ -6654,6 +7001,12 @@ repeating@^2.0.0:
   dependencies:
     is-finite "^1.0.0"
 
+request-progress@0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-0.3.1.tgz#0721c105d8a96ac6b2ce8b2c89ae2d5ecfcf6b3a"
+  dependencies:
+    throttleit "~0.0.2"
+
 request@2.81.0:
   version "2.81.0"
   resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0"
@@ -6751,6 +7104,13 @@ resolve@^1.2.0:
   dependencies:
     path-parse "^1.0.5"
 
+restore-cursor@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
+  dependencies:
+    exit-hook "^1.0.0"
+    onetime "^1.0.0"
+
 restore-cursor@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
@@ -6804,6 +7164,12 @@ rx-lite@*, rx-lite@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
 
+rxjs@^5.0.0-beta.11:
+  version "5.5.7"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.7.tgz#afb3d1642b069b2fbf203903d6501d1acb4cda27"
+  dependencies:
+    symbol-observable "1.0.1"
+
 safe-buffer@5.1.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
@@ -6960,6 +7326,10 @@ slash@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
 
+slice-ansi@0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35"
+
 slice-ansi@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d"
@@ -7085,6 +7455,10 @@ stream-http@^2.3.1:
     to-arraybuffer "^1.0.0"
     xtend "^4.0.0"
 
+stream-to-observable@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/stream-to-observable/-/stream-to-observable-0.1.0.tgz#45bf1d9f2d7dc09bed81f1c307c430e68b84cffe"
+
 strict-uri-encode@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
@@ -7208,6 +7582,12 @@ stylis@^3.2.1:
   version "3.2.19"
   resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.2.19.tgz#3bfeff425e12935cde5faed4a39de06b1aeb5acb"
 
+supports-color@5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.1.0.tgz#058a021d1b619f7ddf3980d712ea3590ce7de3d5"
+  dependencies:
+    has-flag "^2.0.0"
+
 supports-color@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
@@ -7240,6 +7620,10 @@ svgo@^0.7.0:
     sax "~1.2.1"
     whet.extend "~0.9.9"
 
+symbol-observable@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4"
+
 symbol-observable@^1.0.3:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.4.tgz#29bf615d4aa7121bdd898b22d4b3f9bc4e2aa03d"
@@ -7317,6 +7701,10 @@ throat@^4.0.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a"
 
+throttleit@~0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf"
+
 through2@^2.0.1:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be"
@@ -7342,6 +7730,12 @@ timers-browserify@^2.0.2:
   dependencies:
     setimmediate "^1.0.4"
 
+tmp@0.0.31:
+  version "0.0.31"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7"
+  dependencies:
+    os-tmpdir "~1.0.1"
+
 tmp@^0.0.33:
   version "0.0.33"
   resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@@ -7541,7 +7935,7 @@ url-to-options@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
 
-url@^0.11.0:
+url@0.11.0, url@^0.11.0:
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
   dependencies:
@@ -8024,3 +8418,16 @@ yargs@~3.10.0:
     cliui "^2.1.0"
     decamelize "^1.0.0"
     window-size "0.1.0"
+
+yauzl@2.4.1:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005"
+  dependencies:
+    fd-slicer "~1.0.1"
+
+yauzl@2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.8.0.tgz#79450aff22b2a9c5a41ef54e02db907ccfbf9ee2"
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.0.1"