Browse Source

Tests: Adds end-to-end tests skeleton and basic smoke test scenario (#16901)

* Chore: Adds neccessary packages

* Wip: Initial dummy test in place

* Feature: Downloads Chromium if needed

* Fix: Adds global config object

* Refactor: Adds basic e2eScenario

* Build: Adds end to end tests to config

* Build: Changes end to end job

* Build: Adds browsers to image

* Build: Adds failing test

* Refactor: Adds first e2e-test scenario

* Fix: Ignores test output in gitignore

* Refactor: Adds compare screenshots ability

* Refactor: Removes unnecessary code

* Build: Removes jest-puppeteer

* Fix: Replaces test snapshots

* Refactor: Creates output dir if missing

* Refactor: Changes aria-labels to be more consistent

* Docs: Adds section about end to end tests

* Fix: Fixes snapshots

* Docs: Adds information about ENV variables
Hugo Häggmark 6 years ago
parent
commit
a4d287d2e1
45 changed files with 838 additions and 28 deletions
  1. 22 0
      .circleci/config.yml
  2. 2 0
      .gitignore
  3. 30 5
      README.md
  4. 15 0
      jest.config.e2e.js
  5. 13 0
      package.json
  6. 1 1
      public/app/core/components/search/search_results.html
  7. 7 2
      public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx
  8. 2 0
      public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap
  9. 5 1
      public/app/features/dashboard/components/DashNav/DashNavButton.tsx
  10. 9 2
      public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts
  11. 1 1
      public/app/features/dashboard/components/ShareModal/template.html
  12. 6 1
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
  13. 3 1
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx
  14. 1 1
      public/app/features/dashboard/panel_editor/PanelEditor.tsx
  15. 1 0
      public/app/features/datasources/NewDataSourcePage.tsx
  16. 7 1
      public/app/features/datasources/settings/ButtonRow.tsx
  17. 4 2
      public/app/features/datasources/settings/DataSourceSettingsPage.tsx
  18. 1 0
      public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap
  19. 1 1
      public/app/features/panel/panel_directive.ts
  20. 1 1
      public/app/features/panel/panel_header.ts
  21. 3 3
      public/app/partials/login.html
  22. 1 1
      public/app/plugins/datasource/testdata/partials/query.editor.html
  23. 1 1
      public/app/plugins/panel/graph/axes_editor.html
  24. 6 0
      public/e2e-test/core/constants.ts
  25. 51 0
      public/e2e-test/core/images.ts
  26. 29 0
      public/e2e-test/core/launcher.ts
  27. 22 0
      public/e2e-test/core/login.ts
  28. 84 0
      public/e2e-test/core/pageObjects.ts
  29. 110 0
      public/e2e-test/core/pages.ts
  30. 30 0
      public/e2e-test/core/scenario.ts
  31. 22 0
      public/e2e-test/install/install.ts
  32. 13 0
      public/e2e-test/pages/dashboards/createDashboardPage.ts
  33. 14 0
      public/e2e-test/pages/dashboards/dashboardsPage.ts
  34. 20 0
      public/e2e-test/pages/dashboards/saveDashboardModal.ts
  35. 13 0
      public/e2e-test/pages/datasources/addDataSourcePage.ts
  36. 7 0
      public/e2e-test/pages/datasources/dataSources.ts
  37. 22 0
      public/e2e-test/pages/datasources/editDataSourcePage.ts
  38. 26 0
      public/e2e-test/pages/panels/editPanel.ts
  39. 14 0
      public/e2e-test/pages/panels/panel.ts
  40. 12 0
      public/e2e-test/pages/panels/sharePanelModal.ts
  41. 23 0
      public/e2e-test/pages/start/loginPage.ts
  42. 85 0
      public/e2e-test/scenarios/smoke.test.ts
  43. BIN
      public/e2e-test/screenShots/theTruth/smoke-test-scenario.png
  44. 7 1
      tsconfig.json
  45. 91 2
      yarn.lock

+ 22 - 0
.circleci/config.yml

@@ -69,6 +69,28 @@ jobs:
         - run:
             name: cache server tests
             command: './scripts/circle-test-cache-servers.sh'
+  
+  end-to-end-test:
+      docker:
+        - image: circleci/node:8-browsers
+        - image: grafana/grafana:master
+      steps:
+          - run: dockerize -wait tcp://127.0.0.1:3000 -timeout 120s
+          - checkout
+          - restore_cache:
+              key: dependency-cache-{{ checksum "yarn.lock" }}
+          - run:
+              name: yarn install
+              command: 'yarn install --pure-lockfile --no-progress'
+              no_output_timeout: 5m
+          - save_cache:
+              key: dependency-cache-{{ checksum "yarn.lock" }}
+              paths:
+                - node_modules
+          - run:
+              name: run end-to-end tests
+              command: 'env BASE_URL=http://127.0.0.1:3000 yarn e2e-tests'
+              no_output_timeout: 5m
 
   codespell:
     docker:

+ 2 - 0
.gitignore

@@ -84,3 +84,5 @@ debug.test
 /packages/**/dist
 /packages/**/compiled
 /packages/**/.rpt2_cache
+
+theOutput/

+ 30 - 5
README.md

@@ -1,5 +1,5 @@
-[Grafana](https://grafana.com) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana) [![Go Report Card](https://goreportcard.com/badge/github.com/grafana/grafana)](https://goreportcard.com/report/github.com/grafana/grafana) [![codecov](https://codecov.io/gh/grafana/grafana/branch/master/graph/badge.svg)](https://codecov.io/gh/grafana/grafana)
-================
+# [Grafana](https://grafana.com) [![Circle CI](https://circleci.com/gh/grafana/grafana.svg?style=svg)](https://circleci.com/gh/grafana/grafana) [![Go Report Card](https://goreportcard.com/badge/github.com/grafana/grafana)](https://goreportcard.com/report/github.com/grafana/grafana) [![codecov](https://codecov.io/gh/grafana/grafana/branch/master/graph/badge.svg)](https://codecov.io/gh/grafana/grafana)
+
 [Website](https://grafana.com) |
 [Twitter](https://twitter.com/grafana) |
 [Community & Forum](https://community.grafana.com)
@@ -12,12 +12,15 @@ Graphite, Elasticsearch, OpenTSDB, Prometheus and InfluxDB.
 -->
 
 ## Installation
+
 Head to [docs.grafana.org](http://docs.grafana.org/installation/) for documentation or [download](https://grafana.com/get) to get the latest release.
 
 ## Documentation & Support
+
 Be sure to read the [getting started guide](http://docs.grafana.org/guides/gettingstarted/) and the other feature guides.
 
 ## Run from master
+
 If you want to build a package yourself, or contribute - here is a guide for how to do that. You can always find
 the latest master builds [here](https://grafana.com/grafana/download)
 
@@ -48,7 +51,7 @@ go run build.go build
 
 #### Frontend assets
 
-*For this you need Node.js (LTS version).*
+_For this you need Node.js (LTS version)._
 
 ```bash
 yarn install --pure-lockfile
@@ -80,7 +83,7 @@ yarn start:hot
 env GRAFANA_THEME=light yarn start:hot
 ```
 
-*Note: HMR for Angular is not supported. If you edit files in the Angular part of the app, the whole page will reload.*
+_Note: HMR for Angular is not supported. If you edit files in the Angular part of the app, the whole page will reload._
 
 Run tests and rebuild on source change:
 
@@ -128,7 +131,9 @@ In your custom.ini uncomment (remove the leading `;`) sign. And set `app_mode =
 ### Running tests
 
 #### Frontend
+
 Execute all frontend tests
+
 ```bash
 yarn test
 ```
@@ -139,6 +144,7 @@ Writing & watching frontend tests
 - Jest will run all test files that end with the name ".test.ts"
 
 #### Backend
+
 ```bash
 # Run Golang tests using sqlite3 as database (default)
 go test ./pkg/...
@@ -150,6 +156,26 @@ GRAFANA_TEST_DB=mysql go test ./pkg/...
 GRAFANA_TEST_DB=postgres go test ./pkg/...
 ```
 
+#### End-to-end
+
+Execute all end-to-end tests
+
+```bash
+yarn e2e-tests
+```
+
+Execute all end-to-end tests using using a specific url
+
+```bash
+ENV BASE_URL=http://localhost:3333 yarn e2e-tests
+```
+
+Debugging all end-to-end tests (BROWSER=1 will start the browser and SLOWMO=1 will delay each puppeteer operation by 100ms)
+
+```bash
+ENV BROWSER=1 SLOWMO=1 yarn e2e-tests
+```
+
 ### Datasource and dashboard provisioning
 
 [Here](https://github.com/grafana/grafana/tree/master/devenv) you can find helpful scripts and docker-compose setup
@@ -171,4 +197,3 @@ plugin development.
 ## License
 
 Grafana is distributed under [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE).
-

+ 15 - 0
jest.config.e2e.js

@@ -0,0 +1,15 @@
+require('module-alias/register');
+
+module.exports = {
+  verbose: false,
+  transform: {
+    '^.+\\.(ts|tsx)$': 'ts-jest',
+  },
+  moduleDirectories: ['node_modules', 'public'],
+  roots: ['<rootDir>/public/e2e-test'],
+  testRegex: '(\\.|/)(test)\\.(jsx?|tsx?)$',
+  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
+  setupFiles: [],
+  globals: { 'ts-jest': { isolatedModules: true } },
+  setupFilesAfterEnv: ['expect-puppeteer', '<rootDir>/public/e2e-test/install/install.ts'],
+};

+ 13 - 0
package.json

@@ -25,12 +25,16 @@
     "@types/commander": "2.12.2",
     "@types/d3": "4.13.1",
     "@types/enzyme": "3.9.0",
+    "@types/expect-puppeteer": "3.3.1",
     "@types/inquirer": "0.0.43",
     "@types/jest": "24.0.11",
     "@types/jquery": "1.10.35",
     "@types/lodash": "4.14.123",
     "@types/node": "11.13.4",
     "@types/papaparse": "4.5.9",
+    "@types/pixelmatch": "4.0.0",
+    "@types/pngjs": "3.3.2",
+    "@types/puppeteer-core": "1.9.0",
     "@types/react": "16.8.16",
     "@types/react-dom": "16.8.4",
     "@types/react-grid-layout": "0.16.7",
@@ -55,6 +59,7 @@
     "es6-promise": "3.3.1",
     "es6-shim": "0.35.5",
     "execa": "1.0.0",
+    "expect-puppeteer": "4.1.1",
     "expect.js": "0.2.0",
     "expose-loader": "0.7.5",
     "file-loader": "3.0.1",
@@ -85,6 +90,7 @@
     "load-grunt-tasks": "3.5.2",
     "mini-css-extract-plugin": "0.5.0",
     "mocha": "4.1.0",
+    "module-alias": "2.2.0",
     "monaco-editor": "0.15.6",
     "ng-annotate-loader": "0.6.1",
     "ng-annotate-webpack-plugin": "0.3.0",
@@ -94,10 +100,13 @@
     "optimize-css-assets-webpack-plugin": "5.0.1",
     "ora": "3.2.0",
     "phantomjs-prebuilt": "2.1.16",
+    "pixelmatch": "4.0.2",
+    "pngjs": "3.4.0",
     "postcss-browser-reporter": "0.5.0",
     "postcss-loader": "3.0.0",
     "postcss-reporter": "6.0.1",
     "prettier": "1.16.4",
+    "puppeteer-core": "1.15.0",
     "react-hooks-testing-library": "0.3.7",
     "react-hot-loader": "4.8.0",
     "react-test-renderer": "16.8.4",
@@ -140,6 +149,7 @@
     "tslint": "tslint -c tslint.json --project tsconfig.json",
     "typecheck": "tsc --noEmit",
     "jest": "jest --notify --watch",
+    "e2e-tests": "jest --runInBand --config=jest.config.e2e.js",
     "api-tests": "jest --notify --watch --config=tests/api/jest.js",
     "storybook": "cd packages/grafana-ui && yarn storybook",
     "storybook:build": "cd packages/grafana-ui && yarn storybook:build",
@@ -242,5 +252,8 @@
       "**/@types/*",
       "**/@types/*/**"
     ]
+  },
+  "_moduleAliases": {
+    "puppeteer": "node_modules/puppeteer-core"
   }
 }

+ 1 - 1
public/app/core/components/search/search_results.html

@@ -20,7 +20,7 @@
   <div class="search-section__header" ng-show="section.hideHeader"></div>
 
   <div ng-if="section.expanded">
-    <a ng-repeat="item in section.items" class="search-item search-item--indent" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" >
+    <a ng-repeat="item in section.items" class="search-item search-item--indent" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" aria-label="{{::item.title}}">
       <div ng-click="ctrl.toggleSelection(item, $event)" class="center-vh">
         <gf-form-checkbox
            ng-show="ctrl.editable"

+ 7 - 2
public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx

@@ -132,10 +132,15 @@ export class AddPanelWidget extends React.Component<Props, State> {
     dashboard.removePanel(this.props.panel);
   };
 
-  renderOptionLink = (icon, text, onClick) => {
+  renderOptionLink = (icon: string, text: string, onClick) => {
     return (
       <div>
-        <a href="#" onClick={onClick} className="add-panel-widget__link btn btn-inverse">
+        <a
+          href="#"
+          onClick={onClick}
+          className="add-panel-widget__link btn btn-inverse"
+          aria-label={`${text} CTA button`}
+        >
           <div className="add-panel-widget__icon">
             <i className={`gicon gicon-${icon}`} />
           </div>

+ 2 - 0
public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap

@@ -35,6 +35,7 @@ exports[`Render should render component 1`] = `
       >
         <div>
           <a
+            aria-label="Add Query CTA button"
             className="add-panel-widget__link btn btn-inverse"
             href="#"
             onClick={[Function]}
@@ -53,6 +54,7 @@ exports[`Render should render component 1`] = `
         </div>
         <div>
           <a
+            aria-label="Choose Visualization CTA button"
             className="add-panel-widget__link btn btn-inverse"
             href="#"
             onClick={[Function]}

+ 5 - 1
public/app/features/dashboard/components/DashNav/DashNavButton.tsx

@@ -16,7 +16,11 @@ export const DashNavButton: FunctionComponent<Props> = ({ icon, tooltip, classSu
   if (onClick) {
     return (
       <Tooltip content={tooltip}>
-        <button className={`btn navbar-button navbar-button--${classSuffix}`} onClick={onClick}>
+        <button
+          className={`btn navbar-button navbar-button--${classSuffix}`}
+          onClick={onClick}
+          aria-label={`${tooltip} navbar button`}
+        >
           <i className={icon} />
         </button>
       </Tooltip>

+ 9 - 2
public/app/features/dashboard/components/SaveModals/SaveDashboardAsModalCtrl.ts

@@ -17,7 +17,7 @@ const template = `
 		<div class="p-t-2">
 			<div class="gf-form">
 				<label class="gf-form-label width-8">New name</label>
-				<input type="text" class="gf-form-input" ng-model="ctrl.clone.title" give-focus="true" required>
+				<input type="text" class="gf-form-input" ng-model="ctrl.clone.title" give-focus="true" required aria-label="Save dashboard title field">
 			</div>
       <folder-picker initial-folder-id="ctrl.folderId"
                        on-change="ctrl.onFolderChange($folder)"
@@ -34,7 +34,14 @@ const template = `
 		</div>
 
 		<div class="gf-form-button-row text-center">
-			<button type="submit" class="btn btn-primary" ng-click="ctrl.save()" ng-disabled="!ctrl.isValidFolderSelection">Save</button>
+      <button
+        type="submit"
+        class="btn btn-primary"
+        ng-click="ctrl.save()"
+        ng-disabled="!ctrl.isValidFolderSelection"
+        aria-label="Save dashboard button">
+        Save
+      </button>
 			<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
 		</div>
 	</form>

+ 1 - 1
public/app/features/dashboard/components/ShareModal/template.html

@@ -92,7 +92,7 @@
   			</div>
   		</div>
   		<div class="gf-form" ng-show="modeSharePanel">
-  			<a href="{{imageUrl}}" target="_blank"><i class="fa fa-camera"></i> Direct link rendered image</a>
+  			<a href="{{imageUrl}}" target="_blank" aria-label="Link to rendered image"><i class="fa fa-camera"></i> Direct link rendered image</a>
   		</div>
   	</div>
 </script>

+ 6 - 1
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx

@@ -90,7 +90,12 @@ export class PanelHeader extends Component<Props, State> {
           error={error}
         />
         <div className={panelHeaderClass}>
-          <div className="panel-title-container" onClick={this.onMenuToggle} onMouseDown={this.onMouseDown}>
+          <div
+            className="panel-title-container"
+            onClick={this.onMenuToggle}
+            onMouseDown={this.onMouseDown}
+            aria-label="Panel Title"
+          >
             <div className="panel-title">
               <span className="icon-gf panel-alert-icon" />
               <span className="panel-title-text">

+ 3 - 1
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem.tsx

@@ -14,7 +14,9 @@ export const PanelHeaderMenuItem: FC<Props & PanelMenuItem> = props => {
     <li className={isSubMenu ? 'dropdown-submenu' : null}>
       <a onClick={props.onClick}>
         {props.iconClassName && <i className={props.iconClassName} />}
-        <span className="dropdown-item-text">{props.text}</span>
+        <span className="dropdown-item-text" aria-label={`${props.text} panel menu item`}>
+          {props.text}
+        </span>
         {props.shortcut && <span className="dropdown-menu-item-shortcut">{props.shortcut}</span>}
       </a>
       {props.children}

+ 1 - 1
public/app/features/dashboard/panel_editor/PanelEditor.tsx

@@ -145,7 +145,7 @@ function TabItem({ tab, activeTab, onClick }: TabItemParams) {
 
   return (
     <div className="panel-editor-tabs__item" onClick={() => onClick(tab)}>
-      <a className={tabClasses}>
+      <a className={tabClasses} aria-label={`${tab.text} tab button`}>
         <Tooltip content={`${tab.text}`} placement="auto">
           <i className={`gicon gicon-${tab.id}${activeTab === tab.id ? '-active' : ''}`} />
         </Tooltip>

+ 1 - 0
public/app/features/datasources/NewDataSourcePage.tsx

@@ -54,6 +54,7 @@ class NewDataSourcePage extends PureComponent<Props> {
                   onClick={() => this.onDataSourceTypeClicked(plugin)}
                   className="add-data-source-grid-item"
                   key={`${plugin.id}-${index}`}
+                  aria-label={`${plugin.name} datasource plugin`}
                 >
                   <img className="add-data-source-grid-item-logo" src={plugin.info.logos.small} />
                   <span className="add-data-source-grid-item-text">{plugin.name}</span>

+ 7 - 1
public/app/features/datasources/settings/ButtonRow.tsx

@@ -12,7 +12,13 @@ const ButtonRow: FC<Props> = ({ isReadOnly, onDelete, onSubmit, onTest }) => {
   return (
     <div className="gf-form-button-row">
       {!isReadOnly && (
-        <button type="submit" className="btn btn-primary" disabled={isReadOnly} onClick={event => onSubmit(event)}>
+        <button
+          type="submit"
+          className="btn btn-primary"
+          disabled={isReadOnly}
+          onClick={event => onSubmit(event)}
+          aria-label="Save and Test button"
+        >
           Save &amp; Test
         </button>
       )}

+ 4 - 2
public/app/features/datasources/settings/DataSourceSettingsPage.tsx

@@ -212,7 +212,7 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
 
                 <div className="gf-form-group">
                   {testingMessage && (
-                    <div className={`alert-${testingStatus} alert`}>
+                    <div className={`alert-${testingStatus} alert`} aria-label="Datasource settings page Alert">
                       <div className="alert-icon">
                         {testingStatus === 'error' ? (
                           <i className="fa fa-exclamation-triangle" />
@@ -221,7 +221,9 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
                         )}
                       </div>
                       <div className="alert-body">
-                        <div className="alert-title">{testingMessage}</div>
+                        <div className="alert-title" aria-label="Datasource settings page Alert message">
+                          {testingMessage}
+                        </div>
                       </div>
                     </div>
                   )}

+ 1 - 0
public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap

@@ -33,6 +33,7 @@ exports[`Render should render with buttons enabled 1`] = `
   className="gf-form-button-row"
 >
   <button
+    aria-label="Save and Test button"
     className="btn btn-primary"
     disabled={false}
     onClick={[Function]}

+ 1 - 1
public/app/features/panel/panel_directive.ts

@@ -17,7 +17,7 @@ const panelTemplate = `
           <i class="fa fa-spinner fa-spin"></i>
         </span>
 
-        <panel-header class="panel-title-container" panel-ctrl="ctrl"></panel-header>
+        <panel-header class="panel-title-container" panel-ctrl="ctrl" aria-label="Panel Title"></panel-header>
       </div>
 
       <div class="panel-content">

+ 1 - 1
public/app/features/panel/panel_header.ts

@@ -34,7 +34,7 @@ function renderMenuItem(item, ctrl) {
   }
 
   html += `><i class="${item.icon}"></i>`;
-  html += `<span class="dropdown-item-text">${item.text}</span>`;
+  html += `<span class="dropdown-item-text" aria-label="${item.text} panel menu item">${item.text}</span>`;
 
   if (item.shortcut) {
     html += `<span class="dropdown-menu-item-shortcut">${item.shortcut}</span>`;

+ 3 - 3
public/app/partials/login.html

@@ -8,15 +8,15 @@
       <div class="login-inner-box" id="login-view">
         <form name="loginForm" class="login-form-group gf-form-group" ng-hide="disableLoginForm">
           <div class="login-form">
-            <input type="text" name="username" class="gf-form-input login-form-input" required ng-model='formModel.user' placeholder={{loginHint}}
+            <input type="text" name="username" class="gf-form-input login-form-input" required ng-model='formModel.user' placeholder={{loginHint}} aria-label="Username input field"
               autofocus autofill-event-fix>
           </div>
           <div class="login-form">
             <input type="password" name="password" class="gf-form-input login-form-input" required ng-model="formModel.password" id="inputPassword"
-              placeholder="{{passwordHint}}">
+              placeholder="{{passwordHint}}" aria-label="Password input field">
           </div>
           <div class="login-button-group">
-            <button type="submit" class="btn btn-large p-x-2" ng-if="!loggingIn" ng-click="submit();" ng-class="{'btn-inverse': !loginForm.$valid, 'btn-primary': loginForm.$valid}">
+            <button type="submit" aria-label="Login button" class="btn btn-large p-x-2" ng-if="!loggingIn" ng-click="submit();" ng-class="{'btn-inverse': !loginForm.$valid, 'btn-primary': loginForm.$valid}">
               Log In
             </button>
             <button type="submit" class="btn btn-large p-x-2 btn-inverse btn-loading" ng-if="loggingIn">

+ 1 - 1
public/app/plugins/datasource/testdata/partials/query.editor.html

@@ -3,7 +3,7 @@
 		<div class="gf-form">
 			<label class="gf-form-label query-keyword width-7">Scenario</label>
 			<div class="gf-form-select-wrapper width-15">
-				<select class="gf-form-input" ng-model="ctrl.target.scenarioId" ng-options="v.id as v.name for v in ctrl.scenarioList" ng-change="ctrl.scenarioChanged()"></select>
+				<select class="gf-form-input" ng-model="ctrl.target.scenarioId" ng-options="v.id as v.name for v in ctrl.scenarioList" ng-change="ctrl.scenarioChanged()" aria-label="Scenario Select"></select>
 			</div>
 		</div>
 		<div class="gf-form gf-form gf-form--grow" ng-if="ctrl.scenario.stringInput">

+ 1 - 1
public/app/plugins/panel/graph/axes_editor.html

@@ -44,7 +44,7 @@
 		</div>
 	</div>
 
-	<div class="section gf-form-group">
+	<div class="section gf-form-group" aria-label="X-Axis section">
 		<h5 class="section-heading">X-Axis</h5>
 		<gf-form-switch class="gf-form" label="Show" label-class="width-6" checked="ctrl.panel.xaxis.show" on-change="ctrl.render()"></gf-form-switch>
 

+ 6 - 0
public/e2e-test/core/constants.ts

@@ -0,0 +1,6 @@
+export const constants = {
+  baseUrl: process.env.BASE_URL || 'http://localhost:3000',
+  chromiumRevision: '650629',
+  screenShotsTruthDir: './public/e2e-test/screenShots/theTruth',
+  screenShotsOutputDir: './public/e2e-test/screenShots/theOutput',
+};

+ 51 - 0
public/e2e-test/core/images.ts

@@ -0,0 +1,51 @@
+import fs from 'fs';
+import { PNG } from 'pngjs';
+import { Page } from 'puppeteer-core';
+import pixelmatch from 'pixelmatch';
+
+import { constants } from './constants';
+
+export const takeScreenShot = async (page: Page, fileName: string) => {
+  const outputFolderExists = fs.existsSync(constants.screenShotsOutputDir);
+  if (!outputFolderExists) {
+    fs.mkdirSync(constants.screenShotsOutputDir);
+  }
+  const path = `${constants.screenShotsOutputDir}/${fileName}.png`;
+  await page.screenshot({ path, type: 'png', fullPage: false });
+};
+
+export const compareScreenShots = async (fileName: string) =>
+  new Promise(resolve => {
+    let filesRead = 0;
+
+    const doneReading = () => {
+      if (++filesRead < 2) {
+        return;
+      }
+
+      expect(screenShotFromTest.width).toEqual(screenShotFromTruth.width);
+      expect(screenShotFromTest.height).toEqual(screenShotFromTruth.height);
+
+      const diff = new PNG({ width: screenShotFromTest.width, height: screenShotFromTruth.height });
+      const numDiffPixels = pixelmatch(
+        screenShotFromTest.data,
+        screenShotFromTruth.data,
+        diff.data,
+        screenShotFromTest.width,
+        screenShotFromTest.height,
+        { threshold: 0.1 }
+      );
+
+      expect(numDiffPixels).toBe(0);
+      resolve();
+    };
+
+    const screenShotFromTest = fs
+      .createReadStream(`${constants.screenShotsOutputDir}/${fileName}.png`)
+      .pipe(new PNG())
+      .on('parsed', doneReading);
+    const screenShotFromTruth = fs
+      .createReadStream(`${constants.screenShotsTruthDir}/${fileName}.png`)
+      .pipe(new PNG())
+      .on('parsed', doneReading);
+  });

+ 29 - 0
public/e2e-test/core/launcher.ts

@@ -0,0 +1,29 @@
+import puppeteer, { Browser } from 'puppeteer-core';
+
+export const launchBrowser = async (): Promise<Browser> => {
+  const browserFetcher = puppeteer.createBrowserFetcher();
+  const localRevisions = await browserFetcher.localRevisions();
+  if (localRevisions.length === 0) {
+    throw new Error('Could not launch browser because there is no local revisions.');
+  }
+
+  let executablePath = null;
+  executablePath = browserFetcher.revisionInfo(localRevisions[0]).executablePath;
+
+  const browser = await puppeteer.launch({
+    headless: process.env.BROWSER ? false : true,
+    slowMo: process.env.SLOWMO ? 100 : 0,
+    defaultViewport: {
+      width: 1920,
+      height: 1080,
+      deviceScaleFactor: 1,
+      isMobile: false,
+      hasTouch: false,
+      isLandscape: false,
+    },
+    args: ['--start-fullscreen'],
+    executablePath,
+  });
+
+  return browser;
+};

+ 22 - 0
public/e2e-test/core/login.ts

@@ -0,0 +1,22 @@
+import { Page } from 'puppeteer-core';
+
+import { constants } from './constants';
+import { loginPage } from 'e2e-test/pages/start/loginPage';
+
+export const login = async (page: Page) => {
+  await loginPage.init(page);
+  await loginPage.navigateTo();
+
+  await loginPage.pageObjects.username.enter('admin');
+  await loginPage.pageObjects.password.enter('admin');
+  await loginPage.pageObjects.submit.click();
+  await loginPage.waitForResponse();
+};
+
+export const ensureLoggedIn = async (page: Page) => {
+  await page.goto(`${constants.baseUrl}`);
+  if (page.url().indexOf('login') > -1) {
+    console.log('Redirected to login page. Logging in...');
+    await login(page);
+  }
+};

+ 84 - 0
public/e2e-test/core/pageObjects.ts

@@ -0,0 +1,84 @@
+import { Page } from 'puppeteer-core';
+
+export class Selector {
+  static fromAriaLabel = (selector: string) => {
+    return `[aria-label="${selector}"]`;
+  };
+
+  static fromSelector = (selector: string) => {
+    return selector;
+  };
+}
+
+export interface PageObjectType {
+  init: (page: Page) => Promise<void>;
+  exists: () => Promise<void>;
+  containsText: (text: string) => Promise<void>;
+}
+
+export interface ClickablePageObjectType extends PageObjectType {
+  click: () => Promise<void>;
+}
+
+export interface InputPageObjectType extends PageObjectType {
+  enter: (text: string) => Promise<void>;
+}
+
+export interface SelectPageObjectType extends PageObjectType {
+  select: (text: string) => Promise<void>;
+}
+
+export class PageObject implements PageObjectType {
+  protected page: Page = null;
+
+  constructor(protected selector: string) {}
+
+  init = async (page: Page): Promise<void> => {
+    this.page = page;
+  };
+
+  exists = async (): Promise<void> => {
+    const options = { visible: true } as any;
+    await expect(this.page).not.toBeNull();
+    await expect(this.page).toMatchElement(this.selector, options);
+  };
+
+  containsText = async (text: string): Promise<void> => {
+    const options = { visible: true, text } as any;
+    await expect(this.page).not.toBeNull();
+    await expect(this.page).toMatchElement(this.selector, options);
+  };
+}
+
+export class ClickablePageObject extends PageObject implements ClickablePageObjectType {
+  constructor(selector: string) {
+    super(selector);
+  }
+
+  click = async (): Promise<void> => {
+    await expect(this.page).not.toBeNull();
+    await expect(this.page).toClick(this.selector);
+  };
+}
+
+export class InputPageObject extends PageObject implements InputPageObjectType {
+  constructor(selector: string) {
+    super(selector);
+  }
+
+  enter = async (text: string): Promise<void> => {
+    await expect(this.page).not.toBeNull();
+    await expect(this.page).toFill(this.selector, text);
+  };
+}
+
+export class SelectPageObject extends PageObject implements SelectPageObjectType {
+  constructor(selector: string) {
+    super(selector);
+  }
+
+  select = async (text: string): Promise<void> => {
+    await expect(this.page).not.toBeNull();
+    await this.page.select(this.selector, text);
+  };
+}

+ 110 - 0
public/e2e-test/core/pages.ts

@@ -0,0 +1,110 @@
+import { Page } from 'puppeteer-core';
+import { constants } from './constants';
+import { PageObject } from './pageObjects';
+
+export interface ExpectSelectorConfig {
+  selector: string;
+  containsText?: string;
+  isVisible?: boolean;
+}
+
+export interface TestPageType<T> {
+  init: (page: Page) => Promise<void>;
+  getUrl: () => Promise<string>;
+  getUrlWithoutBaseUrl: () => Promise<string>;
+  navigateTo: () => Promise<void>;
+  expectSelector: (config: ExpectSelectorConfig) => Promise<void>;
+  waitForResponse: () => Promise<void>;
+  waitForNavigation: () => Promise<void>;
+  waitFor: (milliseconds: number) => Promise<void>;
+  pageObjects: PageObjects<T>;
+}
+
+type PageObjects<T> = { [P in keyof T]: T[P] };
+
+export interface TestPageConfig<T> {
+  url?: string;
+  pageObjects?: PageObjects<T>;
+}
+
+export class TestPage<T> implements TestPageType<T> {
+  pageObjects: PageObjects<T> = null;
+  private page: Page = null;
+  private pageUrl: string = null;
+
+  constructor(config: TestPageConfig<T>) {
+    if (config.url) {
+      this.pageUrl = `${constants.baseUrl}${config.url}`;
+    }
+    if (config.pageObjects) {
+      this.pageObjects = config.pageObjects;
+    }
+  }
+
+  init = async (page: Page): Promise<void> => {
+    this.page = page;
+
+    if (!this.pageObjects) {
+      return;
+    }
+
+    Object.keys(this.pageObjects).forEach(key => {
+      const pageObject: PageObject = this.pageObjects[key];
+      pageObject.init(page);
+    });
+  };
+
+  navigateTo = async (): Promise<void> => {
+    this.throwIfNotInitialized();
+
+    await this.page.goto(this.pageUrl);
+  };
+
+  expectSelector = async (config: ExpectSelectorConfig): Promise<void> => {
+    this.throwIfNotInitialized();
+
+    const { selector, containsText, isVisible } = config;
+    const visible = isVisible || true;
+    const text = containsText;
+    const options = { visible, text } as any;
+    await expect(this.page).toMatchElement(selector, options);
+  };
+
+  waitForResponse = async (): Promise<void> => {
+    this.throwIfNotInitialized();
+
+    await this.page.waitForResponse(response => response.url() === this.pageUrl && response.status() === 200);
+  };
+
+  waitForNavigation = async (): Promise<void> => {
+    this.throwIfNotInitialized();
+
+    await this.page.waitForNavigation();
+  };
+
+  getUrl = async (): Promise<string> => {
+    this.throwIfNotInitialized();
+
+    return await this.page.url();
+  };
+
+  getUrlWithoutBaseUrl = async (): Promise<string> => {
+    this.throwIfNotInitialized();
+
+    const url = await this.getUrl();
+
+    return url.replace(constants.baseUrl, '');
+  };
+
+  waitFor = async (milliseconds: number) => {
+    this.throwIfNotInitialized();
+
+    await this.page.waitFor(milliseconds);
+  };
+
+  private throwIfNotInitialized = () => {
+    if (!this.page) {
+      throw new Error('pageFactory has not been initilized, did you forget to call init with a page?');
+    }
+  };
+}

+ 30 - 0
public/e2e-test/core/scenario.ts

@@ -0,0 +1,30 @@
+import { Browser, Page } from 'puppeteer-core';
+import { launchBrowser } from './launcher';
+import { ensureLoggedIn } from './login';
+
+export const e2eScenario = (
+  title: string,
+  testDescription: string,
+  callback: (browser: Browser, page: Page) => void
+) => {
+  describe(title, () => {
+    let browser: Browser = null;
+    let page: Page = null;
+
+    beforeAll(async () => {
+      browser = await launchBrowser();
+      page = await browser.newPage();
+      await ensureLoggedIn(page);
+    });
+
+    afterAll(async () => {
+      if (browser) {
+        await browser.close();
+      }
+    });
+
+    it(testDescription, async () => {
+      await callback(browser, page);
+    });
+  });
+};

+ 22 - 0
public/e2e-test/install/install.ts

@@ -0,0 +1,22 @@
+import puppeteer from 'puppeteer-core';
+import { constants } from 'e2e-test/core/constants';
+
+export const downloadBrowserIfNeeded = async (): Promise<void> => {
+  const browserFetcher = puppeteer.createBrowserFetcher();
+  const localRevisions = await browserFetcher.localRevisions();
+  if (localRevisions && localRevisions.length > 0) {
+    console.log('Found a local revision for browser, exiting install.');
+    return;
+  }
+
+  console.log('Did not find any local revisions for browser, downloading latest this might take a while.');
+  await browserFetcher.download(constants.chromiumRevision, (downloaded, total) => {
+    console.log(`Downloaded ${downloaded}bytes of ${total}bytes.`);
+  });
+};
+
+beforeAll(async () => {
+  console.log('Checking Chromium');
+  jest.setTimeout(60 * 1000);
+  await downloadBrowserIfNeeded();
+});

+ 13 - 0
public/e2e-test/pages/dashboards/createDashboardPage.ts

@@ -0,0 +1,13 @@
+import { ClickablePageObjectType, ClickablePageObject, Selector } from 'e2e-test/core/pageObjects';
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface CreateDashboardPage {
+  addQuery: ClickablePageObjectType;
+}
+
+export const createDashboardPage = new TestPage<CreateDashboardPage>({
+  url: '/dashboard/new',
+  pageObjects: {
+    addQuery: new ClickablePageObject(Selector.fromAriaLabel('Add Query CTA button')),
+  },
+});

+ 14 - 0
public/e2e-test/pages/dashboards/dashboardsPage.ts

@@ -0,0 +1,14 @@
+import { ClickablePageObjectType, ClickablePageObject, Selector } from 'e2e-test/core/pageObjects';
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface DashboardsPage {
+  dashboard: ClickablePageObjectType;
+}
+
+export const dashboardsPageFactory = (dashboardTitle: string) =>
+  new TestPage<DashboardsPage>({
+    url: '/dashboards',
+    pageObjects: {
+      dashboard: new ClickablePageObject(Selector.fromAriaLabel(dashboardTitle)),
+    },
+  });

+ 20 - 0
public/e2e-test/pages/dashboards/saveDashboardModal.ts

@@ -0,0 +1,20 @@
+import {
+  ClickablePageObjectType,
+  ClickablePageObject,
+  Selector,
+  InputPageObjectType,
+  InputPageObject,
+} from 'e2e-test/core/pageObjects';
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface SaveDashboardModal {
+  name: InputPageObjectType;
+  save: ClickablePageObjectType;
+}
+
+export const saveDashboardModal = new TestPage<SaveDashboardModal>({
+  pageObjects: {
+    name: new InputPageObject(Selector.fromAriaLabel('Save dashboard title field')),
+    save: new ClickablePageObject(Selector.fromAriaLabel('Save dashboard button')),
+  },
+});

+ 13 - 0
public/e2e-test/pages/datasources/addDataSourcePage.ts

@@ -0,0 +1,13 @@
+import { ClickablePageObject, Selector, ClickablePageObjectType } from 'e2e-test/core/pageObjects';
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface AddDataSourcePage {
+  testDataDB: ClickablePageObjectType;
+}
+
+export const addDataSourcePage = new TestPage<AddDataSourcePage>({
+  url: '/datasources/new',
+  pageObjects: {
+    testDataDB: new ClickablePageObject(Selector.fromAriaLabel('TestData DB datasource plugin')),
+  },
+});

+ 7 - 0
public/e2e-test/pages/datasources/dataSources.ts

@@ -0,0 +1,7 @@
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface DataSourcesPage {}
+
+export const dataSourcesPage = new TestPage<DataSourcesPage>({
+  url: '/datasources',
+});

+ 22 - 0
public/e2e-test/pages/datasources/editDataSourcePage.ts

@@ -0,0 +1,22 @@
+import {
+  ClickablePageObjectType,
+  PageObjectType,
+  ClickablePageObject,
+  PageObject,
+  Selector,
+} from 'e2e-test/core/pageObjects';
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface EditDataSourcePage {
+  saveAndTest: ClickablePageObjectType;
+  alert: PageObjectType;
+  alertMessage: PageObjectType;
+}
+
+export const editDataSourcePage = new TestPage<EditDataSourcePage>({
+  pageObjects: {
+    saveAndTest: new ClickablePageObject(Selector.fromAriaLabel('Save and Test button')),
+    alert: new PageObject(Selector.fromAriaLabel('Datasource settings page Alert')),
+    alertMessage: new PageObject(Selector.fromAriaLabel('Datasource settings page Alert message')),
+  },
+});

+ 26 - 0
public/e2e-test/pages/panels/editPanel.ts

@@ -0,0 +1,26 @@
+import {
+  SelectPageObjectType,
+  SelectPageObject,
+  Selector,
+  ClickablePageObjectType,
+  ClickablePageObject,
+} from 'e2e-test/core/pageObjects';
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface EditPanelPage {
+  queriesTab: ClickablePageObjectType;
+  saveDashboard: ClickablePageObjectType;
+  scenarioSelect: SelectPageObjectType;
+  showXAxis: ClickablePageObjectType;
+  visualizationTab: ClickablePageObjectType;
+}
+
+export const editPanelPage = new TestPage<EditPanelPage>({
+  pageObjects: {
+    queriesTab: new ClickablePageObject(Selector.fromAriaLabel('Queries tab button')),
+    saveDashboard: new ClickablePageObject(Selector.fromAriaLabel('Save dashboard navbar button')),
+    scenarioSelect: new SelectPageObject(Selector.fromAriaLabel('Scenario Select')),
+    showXAxis: new ClickablePageObject(Selector.fromSelector('[aria-label="X-Axis section"] > gf-form-switch')),
+    visualizationTab: new ClickablePageObject(Selector.fromAriaLabel('Visualization tab button')),
+  },
+});

+ 14 - 0
public/e2e-test/pages/panels/panel.ts

@@ -0,0 +1,14 @@
+import { ClickablePageObjectType, ClickablePageObject, Selector } from 'e2e-test/core/pageObjects';
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface Panel {
+  panelTitle: ClickablePageObjectType;
+  share: ClickablePageObjectType;
+}
+
+export const panel = new TestPage<Panel>({
+  pageObjects: {
+    panelTitle: new ClickablePageObject(Selector.fromAriaLabel('Panel Title')),
+    share: new ClickablePageObject(Selector.fromAriaLabel('Share panel menu item')),
+  },
+});

+ 12 - 0
public/e2e-test/pages/panels/sharePanelModal.ts

@@ -0,0 +1,12 @@
+import { ClickablePageObjectType, ClickablePageObject, Selector } from 'e2e-test/core/pageObjects';
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface SharePanelModal {
+  directLinkRenderedImage: ClickablePageObjectType;
+}
+
+export const sharePanelModal = new TestPage<SharePanelModal>({
+  pageObjects: {
+    directLinkRenderedImage: new ClickablePageObject(Selector.fromAriaLabel('Link to rendered image')),
+  },
+});

+ 23 - 0
public/e2e-test/pages/start/loginPage.ts

@@ -0,0 +1,23 @@
+import {
+  InputPageObject,
+  ClickablePageObject,
+  Selector,
+  InputPageObjectType,
+  ClickablePageObjectType,
+} from 'e2e-test/core/pageObjects';
+import { TestPage } from 'e2e-test/core/pages';
+
+export interface LoginPage {
+  username: InputPageObjectType;
+  password: InputPageObjectType;
+  submit: ClickablePageObjectType;
+}
+
+export const loginPage = new TestPage<LoginPage>({
+  url: '/login',
+  pageObjects: {
+    username: new InputPageObject(Selector.fromAriaLabel('Username input field')),
+    password: new InputPageObject(Selector.fromAriaLabel('Password input field')),
+    submit: new ClickablePageObject(Selector.fromAriaLabel('Login button')),
+  },
+});

+ 85 - 0
public/e2e-test/scenarios/smoke.test.ts

@@ -0,0 +1,85 @@
+import { Browser, Page, Target } from 'puppeteer-core';
+
+import { e2eScenario } from 'e2e-test/core/scenario';
+import { addDataSourcePage } from 'e2e-test/pages/datasources/addDataSourcePage';
+import { editDataSourcePage } from 'e2e-test/pages/datasources/editDataSourcePage';
+import { dataSourcesPage } from 'e2e-test/pages/datasources/dataSources';
+import { createDashboardPage } from 'e2e-test/pages/dashboards/createDashboardPage';
+import { saveDashboardModal } from 'e2e-test/pages/dashboards/saveDashboardModal';
+import { dashboardsPageFactory } from 'e2e-test/pages/dashboards/dashboardsPage';
+import { panel } from 'e2e-test/pages/panels/panel';
+import { editPanelPage } from 'e2e-test/pages/panels/editPanel';
+import { constants } from 'e2e-test/core/constants';
+import { sharePanelModal } from 'e2e-test/pages/panels/sharePanelModal';
+import { takeScreenShot, compareScreenShots } from 'e2e-test/core/images';
+
+e2eScenario(
+  'Login scenario, create test data source, dashboard, panel, and export scenario',
+  'should pass',
+  async (browser: Browser, page: Page) => {
+    // Add TestData DB
+    await addDataSourcePage.init(page);
+    await addDataSourcePage.navigateTo();
+    await addDataSourcePage.pageObjects.testDataDB.exists();
+    await addDataSourcePage.pageObjects.testDataDB.click();
+
+    await editDataSourcePage.init(page);
+    await editDataSourcePage.waitForNavigation();
+    await editDataSourcePage.pageObjects.saveAndTest.click();
+    await editDataSourcePage.pageObjects.alert.exists();
+    await editDataSourcePage.pageObjects.alertMessage.containsText('Data source is working');
+
+    // Verify that data source is listed
+    const url = await editDataSourcePage.getUrlWithoutBaseUrl();
+    const expectedUrl = url.substring(1, url.length - 1);
+    const selector = `a[href="${expectedUrl}"]`;
+
+    await dataSourcesPage.init(page);
+    await dataSourcesPage.navigateTo();
+    await dataSourcesPage.expectSelector({ selector });
+
+    // Create a new Dashboard
+    await createDashboardPage.init(page);
+    await createDashboardPage.navigateTo();
+    await createDashboardPage.pageObjects.addQuery.click();
+
+    await editPanelPage.init(page);
+    await editPanelPage.waitForNavigation();
+    await editPanelPage.pageObjects.queriesTab.click();
+    await editPanelPage.pageObjects.scenarioSelect.select('string:csv_metric_values');
+    await editPanelPage.pageObjects.visualizationTab.click();
+    await editPanelPage.pageObjects.showXAxis.click();
+    await editPanelPage.pageObjects.saveDashboard.click();
+
+    // Confirm save modal
+    await saveDashboardModal.init(page);
+    await saveDashboardModal.expectSelector({ selector: 'save-dashboard-as-modal' });
+    const dashboardTitle = new Date().toISOString();
+    await saveDashboardModal.pageObjects.name.enter(dashboardTitle);
+    await saveDashboardModal.pageObjects.save.click();
+
+    // Share the dashboard
+    const dashboardsPage = dashboardsPageFactory(dashboardTitle);
+    await dashboardsPage.init(page);
+    await dashboardsPage.navigateTo();
+    await dashboardsPage.pageObjects.dashboard.exists();
+    await dashboardsPage.pageObjects.dashboard.click();
+
+    await panel.init(page);
+    await panel.pageObjects.panelTitle.click();
+    await panel.pageObjects.share.click();
+
+    // Verify that a new tab is opened
+    const targetPromise = new Promise(resolve => browser.once('targetcreated', resolve));
+    await sharePanelModal.init(page);
+    await sharePanelModal.pageObjects.directLinkRenderedImage.click();
+    const newTarget: Target = (await targetPromise) as Target;
+    expect(newTarget.url()).toContain(`${constants.baseUrl}/render/d-solo`);
+
+    // Take snapshot of page
+    const newPage = await newTarget.page();
+    const fileName = 'smoke-test-scenario';
+    await takeScreenShot(newPage, fileName);
+    await compareScreenShots(fileName);
+  }
+);

BIN
public/e2e-test/screenShots/theTruth/smoke-test-scenario.png


+ 7 - 1
tsconfig.json

@@ -34,5 +34,11 @@
     },
     "skipLibCheck": true
   },
-  "include": ["public/app/**/*.ts", "public/app/**/*.tsx", "public/test/**/*.ts", "public/vendor/**/*.ts"]
+  "include": [
+    "public/app/**/*.ts",
+    "public/app/**/*.tsx",
+    "public/test/**/*.ts",
+    "public/vendor/**/*.ts",
+    "public/e2e-test/**/*.ts"
+  ]
 }

+ 91 - 2
yarn.lock

@@ -2197,6 +2197,14 @@
   resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
   integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
 
+"@types/expect-puppeteer@3.3.1":
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@types/expect-puppeteer/-/expect-puppeteer-3.3.1.tgz#46e5944bf425b86ea13a563c7c8b86901414988d"
+  integrity sha512-3raSnf28NelDtv0ksvQPZs410taJZ4d70vA8sVzmbRPV04fpmQm9/BOxUCloETD/ZI1EXRpv0pzOQKhPTbm4jg==
+  dependencies:
+    "@types/jest" "*"
+    "@types/puppeteer" "*"
+
 "@types/geojson@*":
   version "7946.0.7"
   resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.7.tgz#c8fa532b60a0042219cdf173ca21a975ef0666ad"
@@ -2244,6 +2252,13 @@
   resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89"
   integrity sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA==
 
+"@types/jest@*":
+  version "24.0.12"
+  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.0.12.tgz#0553dd0a5ac744e7dc4e8700da6d3baedbde3e8f"
+  integrity sha512-60sjqMhat7i7XntZckcSGV8iREJyXXI6yFHZkSZvCPUeOnEJ/VP1rU/WpEWQ56mvoh8NhC+sfKAuJRTyGtCOow==
+  dependencies:
+    "@types/jest-diff" "*"
+
 "@types/jest@23.3.14":
   version "23.3.14"
   resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.14.tgz#37daaf78069e7948520474c87b80092ea912520a"
@@ -2298,6 +2313,20 @@
   dependencies:
     "@types/node" "*"
 
+"@types/pixelmatch@4.0.0":
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-4.0.0.tgz#7b017c6c85e96715337f46eafbabc5a44b177530"
+  integrity sha512-pOF+6b0UbePCuPv1BS2k1IEeTk8ae8mhNiHms05s5WM+xV47g8Fb7KQcMn1fkJ9ccbs2IDpgPv+fGmHHvHHnrA==
+  dependencies:
+    "@types/node" "*"
+
+"@types/pngjs@3.3.2":
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-3.3.2.tgz#8ed3bd655ab3a92ea32ada7a21f618e63b93b1d4"
+  integrity sha512-/SBsv93rVnjByzcau24rBwb+N7BHFp2LateaXz1e7m7M0Wzck/ymXTNdWVrCtkuMbwTHAnfdc3X/I/5szsTEAA==
+  dependencies:
+    "@types/node" "*"
+
 "@types/pretty-format@20.0.1":
   version "20.0.1"
   resolved "https://registry.yarnpkg.com/@types/pretty-format/-/pretty-format-20.0.1.tgz#7ce03b403887b087701a2b4534464f48ce7b2f48"
@@ -2308,6 +2337,20 @@
   resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.1.tgz#f1a11e7babb0c3cad68100be381d1e064c68f1f6"
   integrity sha512-CFzn9idOEpHrgdw8JsoTkaDDyRWk1jrzIV8djzcgpq0y9tG4B4lFT+Nxh52DVpDXV+n4+NPNv7M1Dj5uMp6XFg==
 
+"@types/puppeteer-core@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@types/puppeteer-core/-/puppeteer-core-1.9.0.tgz#5ceb397e3ff769081fb07d71289b5009392d24d3"
+  integrity sha512-YJwGTq0a8xZxN7/QDeW59XMdKTRNzDTc8ZVBPDB6J13GgXn1+QzgMA8pAq1/bj2FD0R7xj3nYoZra10b0HLzFw==
+  dependencies:
+    "@types/puppeteer" "*"
+
+"@types/puppeteer@*":
+  version "1.12.3"
+  resolved "https://registry.yarnpkg.com/@types/puppeteer/-/puppeteer-1.12.3.tgz#1309882d368ed21004dfc4520864fdafcf126277"
+  integrity sha512-mJtUPdXqB8THRwiHPbx8pkGYi+8IPf3dMuwJS9hHpr59BwkuLDkkEJ4qMST0k6TbOUXp+wyMJii30ouSkoEtaw==
+  dependencies:
+    "@types/node" "*"
+
 "@types/q@^1.5.1":
   version "1.5.2"
   resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
@@ -7148,6 +7191,11 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
   dependencies:
     homedir-polyfill "^1.0.1"
 
+expect-puppeteer@4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/expect-puppeteer/-/expect-puppeteer-4.1.1.tgz#cda2ab7b6fa27ac24eba273bbb0296a0de538e6d"
+  integrity sha512-xNpu6uYJL9Qrrp4Z31MOpDWK68zAi+2qg5aMQlyOTVZNy7cAgBZiPvKCN0C1JmP3jgPZfcxhetVjZLaw/KcJOQ==
+
 expect.js@0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/expect.js/-/expect.js-0.2.0.tgz#1028533d2c1c363f74a6796ff57ec0520ded2be1"
@@ -7256,7 +7304,7 @@ extglob@^2.0.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
-extract-zip@^1.6.5:
+extract-zip@^1.6.5, extract-zip@^1.6.6:
   version "1.6.7"
   resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.6.7.tgz#a840b4b8af6403264c8db57f4f1a74333ef81fe9"
   integrity sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=
@@ -11418,6 +11466,11 @@ mocha@4.1.0:
     mkdirp "0.5.1"
     supports-color "4.4.0"
 
+module-alias@2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/module-alias/-/module-alias-2.2.0.tgz#a2e32275381642252bf0c51405f7a09a367479b5"
+  integrity sha512-O4bbvlZkHj2LUQhieQWWCr486ddc8X+WwRqi3QGnFKfknaxdHTOB7+xRgeyWHc6arpjgtT5SLLMMTFwUM3/x5w==
+
 moment@2.24.0:
   version "2.24.0"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
@@ -12816,6 +12869,13 @@ pirates@^4.0.1:
   dependencies:
     node-modules-regexp "^1.0.0"
 
+pixelmatch@4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-4.0.2.tgz#8f47dcec5011b477b67db03c243bc1f3085e8854"
+  integrity sha1-j0fc7FARtHe2fbA8JDvB8wheiFQ=
+  dependencies:
+    pngjs "^3.0.0"
+
 pkg-dir@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3"
@@ -12854,6 +12914,11 @@ pn@^1.1.0:
   resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
   integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
 
+pngjs@3.4.0, pngjs@^3.0.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
+  integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
+
 polished@^2.3.3:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/polished/-/polished-2.3.3.tgz#bdbaba962ba8271b0e11aa287f2befd4c87be99a"
@@ -13476,6 +13541,11 @@ progress@^1.1.8:
   resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be"
   integrity sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=
 
+progress@^2.0.1:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
 promise-inflight@^1.0.1, promise-inflight@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
@@ -13584,6 +13654,11 @@ proxy-addr@~2.0.4:
     forwarded "~0.1.2"
     ipaddr.js "1.9.0"
 
+proxy-from-env@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee"
+  integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=
+
 prr@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
@@ -13664,6 +13739,20 @@ punycode@^2.1.0, punycode@^2.1.1:
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
+puppeteer-core@1.15.0:
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-1.15.0.tgz#c8ccf246493349e5d898041f205fbeec4ed845ab"
+  integrity sha512-AH82x8Tx0/JkubeF6U12y8SuVB5vFgsw8lt/Ox5MhXaAktREFiotCTq324U2nPtJUnh2A8yJciDnzAmhbHidqQ==
+  dependencies:
+    debug "^4.1.0"
+    extract-zip "^1.6.6"
+    https-proxy-agent "^2.2.1"
+    mime "^2.0.3"
+    progress "^2.0.1"
+    proxy-from-env "^1.0.0"
+    rimraf "^2.6.1"
+    ws "^6.1.0"
+
 q@^1.1.2:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
@@ -17850,7 +17939,7 @@ ws@^5.2.0:
   dependencies:
     async-limiter "~1.0.0"
 
-ws@^6.0.0:
+ws@^6.0.0, ws@^6.1.0:
   version "6.2.1"
   resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb"
   integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==