2
0
Эх сурвалжийг харах

Add e2e integration tests using Cypress

Updated README.md with basic instructions on how to run Cypress tests
(a special config file with credentials is required).

The following e2e tests are currently available:

- Creating Openstack, VmWare and Azure endpoints.
- Using the created endpoints to execute a replica and a migration.
- Canceling replica and migration.
- Forbiding the deletion of endpoints used in replicas or migrations.
- Deleting replica and migration.
- Deleting the created endpoints.
- Performing scheduler operations.
Sergiu Miclea 8 жил өмнө
parent
commit
9eb32c4b6a
42 өөрчлөгдсөн 2769 нэмэгдсэн , 40 устгасан
  1. 2 1
      .gitignore
  2. 3 0
      README.md
  3. 11 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

+ 11 - 0
cypress.json

@@ -0,0 +1,11 @@
+{
+  "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
+}

+ 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"