diff --git a/.circleci/config.yml b/.circleci/config.yml
index 2d5fd2dfdf9..b7eaa05a08a 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -128,10 +128,10 @@ jobs:
command: 'env BASE_URL=http://127.0.0.1:3000 yarn e2e-tests'
no_output_timeout: 5m
- store_artifacts:
- path: public/e2e-test/screenShots/theTruth
+ path: public/e2e-tests/screenShots/theTruth
destination: expected-screenshots
- store_artifacts:
- path: public/e2e-test/screenShots/theOutput
+ path: public/e2e-tests/screenShots/theOutput
destination: output-screenshots
- run:
name: ci job failed
@@ -167,10 +167,10 @@ jobs:
command: 'env BASE_URL=http://127.0.0.1:3000 yarn e2e-tests'
no_output_timeout: 5m
- store_artifacts:
- path: public/e2e-test/screenShots/theTruth
+ path: public/e2e-tests/screenShots/theTruth
destination: expected-screenshots
- store_artifacts:
- path: public/e2e-test/screenShots/theOutput
+ path: public/e2e-tests/screenShots/theOutput
destination: output-screenshots
- run:
name: ci job failed
diff --git a/.gitignore b/.gitignore
index a97f8285997..15587c08f5c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -90,10 +90,16 @@ debug.test
/packages/**/compiled
/packages/**/.rpt2_cache
-theOutput/
-
# Ignore go local build dependencies
/scripts/go/bin/**
# Ignore compilation stats from `yarn stats`
compilation-stats.json
+
+# e2e tests
+/packages/grafana-e2e/cypress/screenshots
+/packages/grafana-e2e/cypress/videos
+/packages/grafana-e2e/cypress/logs
+/public/e2e-test/screenShots/theOutput
+/public/e2e-tests/screenShots/theOutput/*.png
+
diff --git a/contribute/developer-guide.md b/contribute/developer-guide.md
index 5bd4b1febdf..c4d8771ef5d 100644
--- a/contribute/developer-guide.md
+++ b/contribute/developer-guide.md
@@ -106,10 +106,10 @@ By default, the end-to-end tests assumes Grafana is available on `localhost:3000
BASE_URL=http://localhost:3333 yarn e2e-tests
```
-To follow the tests in the browser while they're running, add the `BROWSER` and `SLOWMO` environment variables:
+To follow the tests in the browser while they're running, use the `yarn e2e-tests:debug` instead.
```
-BROWSER=1 SLOWMO=1 yarn e2e-tests
+yarn e2e-tests:debug
```
## Configure Grafana for development
diff --git a/package.json b/package.json
index d221c052fad..7ce9c9517e0 100644
--- a/package.json
+++ b/package.json
@@ -160,7 +160,9 @@
"typecheck": "tsc --noEmit",
"typecheckPackages": "yarn workspaces run typecheck",
"jest": "jest --notify --watch",
- "e2e-tests": "jest --runInBand --config=jest.config.e2e.js",
+ "e2e": "cd packages/grafana-e2e && yarn start --env BASE_URL=$BASE_URL,CIRCLE_SHA1=$CIRCLE_SHA1,SLOWMO=$SLOWMO --config integrationFolder=../../public/e2e-tests/integration,screenshotsFolder=../../public/e2e-tests/screenShots,fileServerFolder=./cypress,video=false,viewportWidth=1920,viewportHeight=1080,trashAssetsBeforeRuns=false",
+ "e2e-tests": "yarn e2e",
+ "e2e-tests:debug": "SLOWMO=1 yarn e2e --headed --no-exit",
"api-tests": "jest --notify --watch --config=devenv/e2e-api-tests/jest.js",
"storybook": "cd packages/grafana-ui && yarn storybook --ci",
"storybook:build": "cd packages/grafana-ui && yarn storybook:build",
diff --git a/packages/grafana-e2e/CHANGELOG.md b/packages/grafana-e2e/CHANGELOG.md
new file mode 100644
index 00000000000..139597f9cb0
--- /dev/null
+++ b/packages/grafana-e2e/CHANGELOG.md
@@ -0,0 +1,2 @@
+
+
diff --git a/packages/grafana-e2e/README.md b/packages/grafana-e2e/README.md
new file mode 100644
index 00000000000..893b6ce528a
--- /dev/null
+++ b/packages/grafana-e2e/README.md
@@ -0,0 +1,5 @@
+# Grafana End to End Test library
+
+> **@grafana/e2e is currently in ALPHA**. Core API is unstable and can be a subject of breaking changes!
+
+This package allows to run e2e tests
diff --git a/packages/grafana-e2e/cypress.json b/packages/grafana-e2e/cypress.json
new file mode 100644
index 00000000000..f166e263abe
--- /dev/null
+++ b/packages/grafana-e2e/cypress.json
@@ -0,0 +1,3 @@
+{
+ "supportFile": "cypress/support/index.ts"
+}
diff --git a/packages/grafana-e2e/cypress/plugins/cy-compare-images.js b/packages/grafana-e2e/cypress/plugins/cy-compare-images.js
new file mode 100644
index 00000000000..4066eee109b
--- /dev/null
+++ b/packages/grafana-e2e/cypress/plugins/cy-compare-images.js
@@ -0,0 +1,26 @@
+const fs = require('fs');
+const BlinkDiff = require('blink-diff');
+
+function compareSnapshotsPlugin(args) {
+ args.threshold = args.threshold || 0.001;
+
+ return new Promise((resolve, reject) => {
+ const diff = new BlinkDiff({
+ imageAPath: args.pathToFileA,
+ imageBPath: args.pathToFileB,
+ thresholdType: BlinkDiff.THRESHOLD_PERCENT,
+ threshold: args.threshold,
+ imageOutputPath: args.pathToFileA.replace('.png', '.diff.png'),
+ });
+
+ diff.run((error, result) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(result);
+ }
+ });
+ });
+}
+
+module.exports = compareSnapshotsPlugin;
diff --git a/packages/grafana-e2e/cypress/plugins/cy-ts-preprocessor.js b/packages/grafana-e2e/cypress/plugins/cy-ts-preprocessor.js
new file mode 100644
index 00000000000..3bc28e322f2
--- /dev/null
+++ b/packages/grafana-e2e/cypress/plugins/cy-ts-preprocessor.js
@@ -0,0 +1,26 @@
+const wp = require('@cypress/webpack-preprocessor');
+
+const webpackOptions = {
+ resolve: {
+ extensions: ['.ts', '.js']
+ },
+ module: {
+ rules: [
+ {
+ test: /\.ts$/,
+ exclude: [/node_modules/],
+ use: [
+ {
+ loader: 'ts-loader'
+ }
+ ]
+ }
+ ]
+ },
+};
+
+const options = {
+ webpackOptions
+};
+
+module.exports = wp(options);
diff --git a/packages/grafana-e2e/cypress/plugins/index.js b/packages/grafana-e2e/cypress/plugins/index.js
new file mode 100644
index 00000000000..5a0db95f01c
--- /dev/null
+++ b/packages/grafana-e2e/cypress/plugins/index.js
@@ -0,0 +1,15 @@
+const cypressTypeScriptPreprocessor = require('./cy-ts-preprocessor');
+const compareSnapshotsPlugin = require('./cy-compare-images');
+
+module.exports = on => {
+ // yarn build fails with:
+ // >> /Users/hugo/go/src/github.com/grafana/grafana/node_modules/stringmap/stringmap.js:99
+ // >> throw new Error("StringMap expected string key");
+ // on('task', {
+ // failed: require('cypress-failed-log/src/failed')(),
+ // });
+ on('file:preprocessor', cypressTypeScriptPreprocessor);
+ on('task', {
+ compareSnapshotsPlugin
+ });
+};
diff --git a/packages/grafana-e2e/cypress/support/commands.ts b/packages/grafana-e2e/cypress/support/commands.ts
new file mode 100644
index 00000000000..a257e75ea72
--- /dev/null
+++ b/packages/grafana-e2e/cypress/support/commands.ts
@@ -0,0 +1,23 @@
+interface CompareSnapshotArgs {
+ pathToFileA: string;
+ pathToFileB: string;
+ threshold?: number;
+}
+
+Cypress.Commands.add('compareSnapshot', (args: CompareSnapshotArgs) => {
+ cy.task('compareSnapshotsPlugin', args).then((results: any) => {
+ if (results.code <= 1) {
+ let msg = `\nThe screenshot:[${args.pathToFileA}] differs from :[${args.pathToFileB}]`;
+ msg += '\n';
+ msg += '\nCheck the Artifacts tab in the CircleCi build output for the actual screenshots.';
+ msg += '\n';
+ msg += '\n If the difference between expected and outcome is NOT acceptable then do the following:';
+ msg += '\n - Check the code for changes that causes this difference, fix that and retry.';
+ msg += '\n';
+ msg += '\n If the difference between expected and outcome is acceptable then do the following:';
+ msg += '\n - Replace the expected image with the outcome and retry.';
+ msg += '\n';
+ throw new Error(msg);
+ }
+ });
+});
diff --git a/packages/grafana-e2e/cypress/support/index.d.ts b/packages/grafana-e2e/cypress/support/index.d.ts
new file mode 100644
index 00000000000..dc0043dfcc0
--- /dev/null
+++ b/packages/grafana-e2e/cypress/support/index.d.ts
@@ -0,0 +1,7 @@
+///
+
+declare namespace Cypress {
+ interface Chainable {
+ compareSnapshot(args: CompareSnapshotArgs): void;
+ }
+}
diff --git a/packages/grafana-e2e/cypress/support/index.ts b/packages/grafana-e2e/cypress/support/index.ts
new file mode 100644
index 00000000000..c2723ed6250
--- /dev/null
+++ b/packages/grafana-e2e/cypress/support/index.ts
@@ -0,0 +1,25 @@
+// yarn build fails with:
+// >> /Users/hugo/go/src/github.com/grafana/grafana/node_modules/stringmap/stringmap.js:99
+// >> throw new Error("StringMap expected string key");
+// require('cypress-failed-log');
+import './commands';
+
+Cypress.Screenshot.defaults({
+ screenshotOnRunFailure: false,
+});
+
+const COMMAND_DELAY = 1000;
+
+if (Cypress.env('SLOWMO')) {
+ for (const command of ['visit', 'click', 'trigger', 'type', 'clear', 'reload', 'contains', 'then']) {
+ Cypress.Commands.overwrite(command, (originalFn, ...args) => {
+ const origVal = originalFn(...args);
+
+ return new Promise(resolve => {
+ setTimeout(() => {
+ resolve(origVal);
+ }, COMMAND_DELAY);
+ });
+ });
+ }
+}
diff --git a/packages/grafana-e2e/cypress/tsconfig.json b/packages/grafana-e2e/cypress/tsconfig.json
new file mode 100644
index 00000000000..18a942e80c0
--- /dev/null
+++ b/packages/grafana-e2e/cypress/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "types": ["cypress"]
+ },
+ "include": ["**/*.ts"]
+}
diff --git a/packages/grafana-e2e/index.js b/packages/grafana-e2e/index.js
new file mode 100644
index 00000000000..d1a4363350e
--- /dev/null
+++ b/packages/grafana-e2e/index.js
@@ -0,0 +1,7 @@
+'use strict'
+
+if (process.env.NODE_ENV === 'production') {
+ module.exports = require('./index.production.js');
+} else {
+ module.exports = require('./index.development.js');
+}
diff --git a/packages/grafana-e2e/package.json b/packages/grafana-e2e/package.json
new file mode 100644
index 00000000000..179be7aed15
--- /dev/null
+++ b/packages/grafana-e2e/package.json
@@ -0,0 +1,41 @@
+{
+ "author": "Grafana Labs",
+ "license": "Apache-2.0",
+ "name": "@grafana/e2e",
+ "version": "6.4.0-pre",
+ "description": "Grafana End to End Test Library",
+ "keywords": [
+ "grafana",
+ "e2e",
+ "typescript"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "http://github.com/grafana/grafana.git"
+ },
+ "main": "src/index.ts",
+ "scripts": {
+ "tslint": "tslint -c tslint.json --project tsconfig.json",
+ "typecheck": "tsc --noEmit",
+ "clean": "rimraf ./dist ./compiled",
+ "bundle": "rollup -c rollup.config.ts",
+ "build": "grafana-toolkit package:build --scope=e2e",
+ "open": "cypress open",
+ "start": "cypress run"
+ },
+ "devDependencies": {
+ "@cypress/webpack-preprocessor": "4.1.1",
+ "blink-diff": "1.0.13",
+ "cypress": "3.6.1",
+ "rollup": "1.6.0",
+ "rollup-plugin-commonjs": "9.2.1",
+ "rollup-plugin-node-resolve": "4.0.1",
+ "rollup-plugin-sourcemaps": "0.4.2",
+ "rollup-plugin-terser": "4.0.4",
+ "rollup-plugin-typescript2": "0.19.3",
+ "rollup-plugin-visualizer": "0.9.2",
+ "ts-loader": "6.2.1",
+ "typescript": "3.7.2"
+ },
+ "types": "src/index.ts"
+}
diff --git a/packages/grafana-e2e/rollup.config.ts b/packages/grafana-e2e/rollup.config.ts
new file mode 100644
index 00000000000..caeb03a28cf
--- /dev/null
+++ b/packages/grafana-e2e/rollup.config.ts
@@ -0,0 +1,33 @@
+import resolve from 'rollup-plugin-node-resolve';
+import commonjs from 'rollup-plugin-commonjs';
+import sourceMaps from 'rollup-plugin-sourcemaps';
+import { terser } from 'rollup-plugin-terser';
+
+const pkg = require('./package.json');
+
+const libraryName = pkg.name;
+
+const buildCjsPackage = ({ env }) => {
+ return {
+ input: `compiled/index.js`,
+ output: [
+ {
+ file: `dist/index.${env}.js`,
+ name: libraryName,
+ format: 'cjs',
+ sourcemap: true,
+ exports: 'named',
+ globals: {},
+ },
+ ],
+ plugins: [
+ commonjs({
+ include: /node_modules/,
+ }),
+ resolve(),
+ sourceMaps(),
+ env === 'production' && terser(),
+ ],
+ };
+};
+export default [buildCjsPackage({ env: 'development' }), buildCjsPackage({ env: 'production' })];
diff --git a/packages/grafana-e2e/src/flows/addDashboard.ts b/packages/grafana-e2e/src/flows/addDashboard.ts
new file mode 100644
index 00000000000..e3ee18437f2
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/addDashboard.ts
@@ -0,0 +1,19 @@
+import { e2e } from '../index';
+import { Url } from '../support/url';
+
+export const addDashboard = async (): Promise<{ dashboardTitle: string; uid: string }> => {
+ e2e.pages.AddDashboard.visit();
+
+ const dashboardTitle = e2e.flows.saveNewDashboard();
+
+ return new Promise(resolve => {
+ e2e()
+ .url()
+ .then((url: string) => {
+ resolve({
+ dashboardTitle,
+ uid: Url.getDashboardUid(url),
+ });
+ });
+ });
+};
diff --git a/packages/grafana-e2e/src/flows/addDataSource.ts b/packages/grafana-e2e/src/flows/addDataSource.ts
new file mode 100644
index 00000000000..69aaaa700ec
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/addDataSource.ts
@@ -0,0 +1,19 @@
+import { e2e } from '../index';
+
+export const addDataSource = (pluginName?: string): string => {
+ pluginName = pluginName || 'TestData DB';
+
+ e2e.pages.DataSources.visit();
+ e2e.pages.DataSources.addDataSource().click();
+
+ e2e.pages.AddDataSource.dataSourcePlugins(pluginName).click();
+
+ const dataSourceName = `e2e-${new Date().getTime()}`;
+ e2e.pages.DataSource.name().clear();
+ e2e.pages.DataSource.name().type(dataSourceName);
+ e2e.pages.DataSource.saveAndTest().click();
+ e2e.pages.DataSource.alert().should('exist');
+ e2e.pages.DataSource.alertMessage().should('contain.text', 'Data source is working');
+
+ return dataSourceName;
+};
diff --git a/packages/grafana-e2e/src/flows/assertSuccessNotification.ts b/packages/grafana-e2e/src/flows/assertSuccessNotification.ts
new file mode 100644
index 00000000000..0d1507a1a9b
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/assertSuccessNotification.ts
@@ -0,0 +1,7 @@
+import { e2e } from '../index';
+
+export const assertSuccessNotification = () => {
+ e2e()
+ .get('.alert-success')
+ .should('exist');
+};
diff --git a/packages/grafana-e2e/src/flows/deleteDashboard.ts b/packages/grafana-e2e/src/flows/deleteDashboard.ts
new file mode 100644
index 00000000000..fde36cdfde2
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/deleteDashboard.ts
@@ -0,0 +1,27 @@
+import { Url } from '../support/url';
+import { e2e } from '../index';
+
+export const deleteDashboard = (dashBoardUid: string) => {
+ e2e().request('DELETE', Url.fromBaseUrl(`/api/dashboards/uid/${dashBoardUid}`));
+
+ /* https://github.com/cypress-io/cypress/issues/2831
+ Flows.openDashboard(dashboardName);
+
+ Pages.Dashboard.settings().click();
+
+ Pages.DashboardSettings.deleteDashBoard().click();
+
+ Pages.ConfirmModal.delete().click();
+
+ Flows.assertSuccessNotification();
+
+ Pages.Dashboards.visit();
+ Pages.Dashboards.dashboards().each(item => {
+ const text = item.text();
+ Cypress.log({ message: [text] });
+ if (text && text.indexOf(dashboardName) !== -1) {
+ expect(false).equals(true, `Dashboard ${dashboardName} was found although it was deleted.`);
+ }
+ });
+ */
+};
diff --git a/packages/grafana-e2e/src/flows/deleteDataSource.ts b/packages/grafana-e2e/src/flows/deleteDataSource.ts
new file mode 100644
index 00000000000..dd1ec8153d7
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/deleteDataSource.ts
@@ -0,0 +1,23 @@
+import { Url } from '../support/url';
+import { e2e } from '../index';
+
+export const deleteDataSource = (dataSourceName: string) => {
+ e2e().request('DELETE', Url.fromBaseUrl(`/api/datasources/name/${dataSourceName}`));
+
+ /* https://github.com/cypress-io/cypress/issues/2831
+ Pages.DataSources.visit();
+ Pages.DataSources.dataSources(dataSourceName).click();
+
+ Pages.DataSource.delete().click();
+
+ Pages.ConfirmModal.delete().click();
+
+ Pages.DataSources.visit();
+ Pages.DataSources.dataSources().each(item => {
+ const text = item.text();
+ if (text && text.indexOf(dataSourceName) !== -1) {
+ expect(false).equals(true, `Data source ${dataSourceName} was found although it was deleted.`);
+ }
+ });
+ */
+};
diff --git a/packages/grafana-e2e/src/flows/index.ts b/packages/grafana-e2e/src/flows/index.ts
new file mode 100644
index 00000000000..b0c5c925a73
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/index.ts
@@ -0,0 +1,21 @@
+import { login } from './login';
+import { addDataSource } from './addDataSource';
+import { deleteDataSource } from './deleteDataSource';
+import { addDashboard } from './addDashboard';
+import { assertSuccessNotification } from './assertSuccessNotification';
+import { deleteDashboard } from './deleteDashboard';
+import { openDashboard } from './openDashboard';
+import { saveNewDashboard } from './saveNewDashboard';
+import { saveDashboard } from './saveDashboard';
+
+export const Flows = {
+ login,
+ addDataSource,
+ deleteDataSource,
+ addDashboard,
+ assertSuccessNotification,
+ deleteDashboard,
+ openDashboard,
+ saveNewDashboard,
+ saveDashboard,
+};
diff --git a/packages/grafana-e2e/src/flows/login.ts b/packages/grafana-e2e/src/flows/login.ts
new file mode 100644
index 00000000000..d40b2e30392
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/login.ts
@@ -0,0 +1,8 @@
+import { e2e } from '../index';
+
+export const login = (username: string, password: string) => {
+ e2e.pages.Login.visit();
+ e2e.pages.Login.username().type(username);
+ e2e.pages.Login.password().type(password);
+ e2e.pages.Login.submit().click();
+};
diff --git a/packages/grafana-e2e/src/flows/openDashboard.ts b/packages/grafana-e2e/src/flows/openDashboard.ts
new file mode 100644
index 00000000000..d595ecb6e60
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/openDashboard.ts
@@ -0,0 +1,6 @@
+import { e2e } from '../index';
+
+export const openDashboard = (dashboardTitle: string) => {
+ e2e.pages.Dashboards.visit();
+ e2e.pages.Dashboards.dashboards(dashboardTitle).click();
+};
diff --git a/packages/grafana-e2e/src/flows/saveDashboard.ts b/packages/grafana-e2e/src/flows/saveDashboard.ts
new file mode 100644
index 00000000000..cd20ad95d22
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/saveDashboard.ts
@@ -0,0 +1,9 @@
+import { e2e } from '../index';
+
+export const saveDashboard = () => {
+ e2e.pages.Dashboard.toolbarItems('Save dashboard').click();
+
+ e2e.pages.SaveDashboardModal.save().click();
+
+ e2e.flows.assertSuccessNotification();
+};
diff --git a/packages/grafana-e2e/src/flows/saveNewDashboard.ts b/packages/grafana-e2e/src/flows/saveNewDashboard.ts
new file mode 100644
index 00000000000..78a89968738
--- /dev/null
+++ b/packages/grafana-e2e/src/flows/saveNewDashboard.ts
@@ -0,0 +1,14 @@
+import { e2e } from '../index';
+
+export const saveNewDashboard = () => {
+ e2e.pages.Dashboard.toolbarItems('Save dashboard').click();
+
+ const dashboardTitle = `e2e-${new Date().getTime()}`;
+ e2e.pages.SaveDashboardAsModal.newName().clear();
+ e2e.pages.SaveDashboardAsModal.newName().type(dashboardTitle);
+ e2e.pages.SaveDashboardAsModal.save().click();
+
+ e2e.flows.assertSuccessNotification();
+
+ return dashboardTitle;
+};
diff --git a/packages/grafana-e2e/src/index.ts b/packages/grafana-e2e/src/index.ts
new file mode 100644
index 00000000000..1865a55a8bc
--- /dev/null
+++ b/packages/grafana-e2e/src/index.ts
@@ -0,0 +1,3 @@
+import { e2e } from './noTypeCheck';
+
+export { e2e };
diff --git a/packages/grafana-e2e/src/noTypeCheck.ts b/packages/grafana-e2e/src/noTypeCheck.ts
new file mode 100644
index 00000000000..04fbfb4ff51
--- /dev/null
+++ b/packages/grafana-e2e/src/noTypeCheck.ts
@@ -0,0 +1,26 @@
+// @ts-nocheck
+// importing the e2e package in Grafana will cause transpile errors because
+// Cypress is an unknown type. Adding the Cypress types would overwrite all jest test types like
+// toBe, toEqual and so forth. That's why this file is not type checked and will be so until we
+// can solve the above mentioned issue with Cypress/Jest.
+import { e2eScenario, ScenarioArguments } from './support/scenario';
+import { Pages } from './pages';
+import { Flows } from './flows';
+
+export type SelectorFunction = (text?: string) => Cypress.Chainable;
+export type SelectorObject = {
+ visit: () => Cypress.Chainable;
+ selectors: S;
+};
+
+const e2eObject = {
+ env: (args: string) => Cypress.env(args),
+ config: () => Cypress.config(),
+ blobToBase64String: (blob: any) => Cypress.Blob.blobToBase64String(blob),
+ imgSrcToBlob: (url: string) => Cypress.Blob.imgSrcToBlob(url),
+ scenario: (args: ScenarioArguments) => e2eScenario(args),
+ pages: Pages,
+ flows: Flows,
+};
+
+export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject);
diff --git a/packages/grafana-e2e/src/pages/addDashboard.ts b/packages/grafana-e2e/src/pages/addDashboard.ts
new file mode 100644
index 00000000000..cc59f40e3ac
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/addDashboard.ts
@@ -0,0 +1,8 @@
+import { pageFactory } from '../support';
+
+export const AddDashboard = pageFactory({
+ url: '/dashboard/new',
+ selectors: {
+ ctaButtons: (text: string) => `Add Panel Widget CTA Button ${text}`,
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/addDataSource.ts b/packages/grafana-e2e/src/pages/addDataSource.ts
new file mode 100644
index 00000000000..ad8682b40d4
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/addDataSource.ts
@@ -0,0 +1,8 @@
+import { pageFactory } from '../support';
+
+export const AddDataSource = pageFactory({
+ url: '/datasources/new',
+ selectors: {
+ dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`,
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/confirmModal.ts b/packages/grafana-e2e/src/pages/confirmModal.ts
new file mode 100644
index 00000000000..30d7bca551e
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/confirmModal.ts
@@ -0,0 +1,8 @@
+import { pageFactory } from '../support';
+
+export const ConfirmModal = pageFactory({
+ url: '',
+ selectors: {
+ delete: 'Confirm Modal Danger Button',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/dashboard.ts b/packages/grafana-e2e/src/pages/dashboard.ts
new file mode 100644
index 00000000000..f1e36162bc2
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/dashboard.ts
@@ -0,0 +1,9 @@
+import { pageFactory } from '../support';
+
+export const Dashboard = pageFactory({
+ url: '',
+ selectors: {
+ toolbarItems: (button: string) => `Dashboard navigation bar button ${button}`,
+ backArrow: 'Dashboard settings Go Back button',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/dashboardSettings.ts b/packages/grafana-e2e/src/pages/dashboardSettings.ts
new file mode 100644
index 00000000000..77bbe1179a5
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/dashboardSettings.ts
@@ -0,0 +1,11 @@
+import { pageFactory } from '../support';
+
+export const DashboardSettings = pageFactory({
+ url: '',
+ selectors: {
+ deleteDashBoard: 'Dashboard settings page delete dashboard button',
+ sectionItems: 'Dashboard settings section item',
+ saveDashBoard: 'Dashboard settings aside actions Save button',
+ saveAsDashBoard: 'Dashboard settings aside actions Save As button',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/dashboards.ts b/packages/grafana-e2e/src/pages/dashboards.ts
new file mode 100644
index 00000000000..12bebbdb084
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/dashboards.ts
@@ -0,0 +1,8 @@
+import { pageFactory } from '../support';
+
+export const Dashboards = pageFactory({
+ url: '/dashboards',
+ selectors: {
+ dashboards: (title: string) => `Dashboard search item ${title}`,
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/datasource.ts b/packages/grafana-e2e/src/pages/datasource.ts
new file mode 100644
index 00000000000..2a10e63d618
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/datasource.ts
@@ -0,0 +1,12 @@
+import { pageFactory } from '../support';
+
+export const DataSource = pageFactory({
+ url: '',
+ selectors: {
+ name: 'Data source settings page name input field',
+ delete: 'Data source settings page Delete button',
+ saveAndTest: 'Data source settings page Save and Test button',
+ alert: 'Data source settings page Alert',
+ alertMessage: 'Data source settings page Alert message',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/datasources.ts b/packages/grafana-e2e/src/pages/datasources.ts
new file mode 100644
index 00000000000..0b3e8021aa0
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/datasources.ts
@@ -0,0 +1,9 @@
+import { pageFactory } from '../support';
+
+export const DataSources = pageFactory({
+ url: '/datasources',
+ selectors: {
+ dataSources: (dataSourceName: string) => `Data source list item ${dataSourceName}`,
+ addDataSource: () => '.page-action-bar > .btn',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/editPanel.ts b/packages/grafana-e2e/src/pages/editPanel.ts
new file mode 100644
index 00000000000..1dfdb6dcd02
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/editPanel.ts
@@ -0,0 +1,8 @@
+import { pageFactory } from '../support';
+
+export const EditPanel = pageFactory({
+ url: '',
+ selectors: {
+ tabItems: (text: string) => `Edit panel tab item ${text}`,
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/graph/index.ts b/packages/grafana-e2e/src/pages/graph/index.ts
new file mode 100644
index 00000000000..62f1e7711e0
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/graph/index.ts
@@ -0,0 +1,5 @@
+import { VisualizationTab } from './visualizationTab';
+
+export const Graph = {
+ VisualizationTab,
+};
diff --git a/packages/grafana-e2e/src/pages/graph/visualizationTab.ts b/packages/grafana-e2e/src/pages/graph/visualizationTab.ts
new file mode 100644
index 00000000000..a059f95fec0
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/graph/visualizationTab.ts
@@ -0,0 +1,8 @@
+import { pageFactory } from '../../support';
+
+export const VisualizationTab = pageFactory({
+ url: '',
+ selectors: {
+ xAxisSection: 'X-Axis section',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/index.ts b/packages/grafana-e2e/src/pages/index.ts
new file mode 100644
index 00000000000..6c4f10c1b3b
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/index.ts
@@ -0,0 +1,41 @@
+import { Login } from './login';
+import { AddDataSource } from './addDataSource';
+import { DataSource } from './datasource';
+import { DataSources } from './datasources';
+import { ConfirmModal } from './confirmModal';
+import { AddDashboard } from './addDashboard';
+import { Dashboard } from './dashboard';
+import { SaveDashboardAsModal } from './saveDashboardAsModal';
+import { Dashboards } from './dashboards';
+import { DashboardSettings } from './dashboardSettings';
+import { EditPanel } from './editPanel';
+import { TestData } from './testdata';
+import { Graph } from './graph';
+import { SaveDashboardModal } from './saveDashboardModal';
+import { Panel } from './panel';
+import { SharePanelModal } from './sharePanelModal';
+
+export const Pages = {
+ Login,
+ DataSource,
+ DataSources,
+ AddDataSource,
+ ConfirmModal,
+ AddDashboard,
+ Dashboard,
+ Dashboards,
+ SaveDashboardAsModal,
+ SaveDashboardModal,
+ DashboardSettings,
+ SharePanelModal,
+ Panels: {
+ Panel,
+ EditPanel,
+ DataSource: {
+ TestData,
+ },
+ Visualization: {
+ Graph,
+ },
+ },
+};
diff --git a/packages/grafana-e2e/src/pages/login.ts b/packages/grafana-e2e/src/pages/login.ts
new file mode 100644
index 00000000000..73ede1a931c
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/login.ts
@@ -0,0 +1,10 @@
+import { pageFactory } from '../support';
+
+export const Login = pageFactory({
+ url: '/login',
+ selectors: {
+ username: 'Username input field',
+ password: 'Password input field',
+ submit: 'Login button',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/panel.ts b/packages/grafana-e2e/src/pages/panel.ts
new file mode 100644
index 00000000000..6411e139db0
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/panel.ts
@@ -0,0 +1,9 @@
+import { pageFactory } from '../support';
+
+export const Panel = pageFactory({
+ url: '',
+ selectors: {
+ title: (title: string) => `Panel header title item ${title}`,
+ headerItems: (item: string) => `Panel header item ${item}`,
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/saveDashboardAsModal.ts b/packages/grafana-e2e/src/pages/saveDashboardAsModal.ts
new file mode 100644
index 00000000000..a24660a2a15
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/saveDashboardAsModal.ts
@@ -0,0 +1,9 @@
+import { pageFactory } from '../support';
+
+export const SaveDashboardAsModal = pageFactory({
+ url: '',
+ selectors: {
+ newName: 'Save dashboard title field',
+ save: 'Save dashboard button',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/saveDashboardModal.ts b/packages/grafana-e2e/src/pages/saveDashboardModal.ts
new file mode 100644
index 00000000000..f1131808325
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/saveDashboardModal.ts
@@ -0,0 +1,8 @@
+import { pageFactory } from '../support';
+
+export const SaveDashboardModal = pageFactory({
+ url: '',
+ selectors: {
+ save: 'Dashboard settings Save Dashboard Modal Save button',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/sharePanelModal.ts b/packages/grafana-e2e/src/pages/sharePanelModal.ts
new file mode 100644
index 00000000000..eb2951125e5
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/sharePanelModal.ts
@@ -0,0 +1,8 @@
+import { pageFactory } from '../support';
+
+export const SharePanelModal = pageFactory({
+ url: '',
+ selectors: {
+ linkToRenderedImage: 'Link to rendered image',
+ },
+});
diff --git a/packages/grafana-e2e/src/pages/testdata/index.ts b/packages/grafana-e2e/src/pages/testdata/index.ts
new file mode 100644
index 00000000000..b103702bbc3
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/testdata/index.ts
@@ -0,0 +1,5 @@
+import { QueryTab } from './queryTab';
+
+export const TestData = {
+ QueryTab,
+};
diff --git a/packages/grafana-e2e/src/pages/testdata/queryTab.ts b/packages/grafana-e2e/src/pages/testdata/queryTab.ts
new file mode 100644
index 00000000000..171f365e241
--- /dev/null
+++ b/packages/grafana-e2e/src/pages/testdata/queryTab.ts
@@ -0,0 +1,8 @@
+import { pageFactory } from '../../support';
+
+export const QueryTab = pageFactory({
+ url: '',
+ selectors: {
+ scenarioSelect: 'Test Data Query scenario select',
+ },
+});
diff --git a/packages/grafana-e2e/src/support/index.ts b/packages/grafana-e2e/src/support/index.ts
new file mode 100644
index 00000000000..74ba6812b5b
--- /dev/null
+++ b/packages/grafana-e2e/src/support/index.ts
@@ -0,0 +1,5 @@
+import { ScenarioContext } from './scenario';
+
+export { ScenarioContext };
+export * from './types';
+export * from './selector';
diff --git a/packages/grafana-e2e/src/support/scenario.ts b/packages/grafana-e2e/src/support/scenario.ts
new file mode 100644
index 00000000000..894f1c9ff7b
--- /dev/null
+++ b/packages/grafana-e2e/src/support/scenario.ts
@@ -0,0 +1,68 @@
+import { e2e } from '../index';
+
+export interface ScenarioContext {
+ dataSourceName?: string;
+ dashboardTitle?: string;
+ dashboardUid?: string;
+}
+
+export interface ScenarioArguments {
+ describeName: string;
+ itName: string;
+ scenario: (context: ScenarioContext) => void;
+ skipScenario?: boolean;
+ addScenarioDataSource?: boolean;
+ addScenarioDashBoard?: boolean;
+}
+
+export const e2eScenario = ({
+ describeName,
+ itName,
+ scenario,
+ skipScenario = false,
+ addScenarioDataSource = false,
+ addScenarioDashBoard = false,
+}: ScenarioArguments) => {
+ describe(describeName, () => {
+ if (skipScenario) {
+ it.skip(itName, () => {
+ // @ts-ignore yarn start in root throws error otherwise
+ expect(false).equals(true);
+ });
+ return;
+ }
+
+ let scenarioDataSource: string;
+ let scenarioDashBoardTitle: string;
+ let scenarioDashBoardUid: string;
+
+ beforeEach(async () => {
+ e2e.flows.login('admin', 'admin');
+ if (addScenarioDataSource) {
+ scenarioDataSource = e2e.flows.addDataSource('TestData DB');
+ }
+ if (addScenarioDashBoard) {
+ const { dashboardTitle, uid } = await e2e.flows.addDashboard();
+ scenarioDashBoardTitle = dashboardTitle;
+ scenarioDashBoardUid = uid;
+ }
+ });
+
+ afterEach(() => {
+ if (scenarioDataSource) {
+ e2e.flows.deleteDataSource(scenarioDataSource);
+ }
+ if (scenarioDashBoardUid) {
+ e2e.flows.deleteDashboard(scenarioDashBoardUid);
+ }
+ });
+
+ it(itName, () => {
+ scenario({
+ dashboardTitle: scenarioDashBoardTitle,
+ dashboardUid: scenarioDashBoardUid,
+ dataSourceName: scenarioDataSource,
+ });
+ });
+ });
+};
diff --git a/packages/grafana-e2e/src/support/selector.ts b/packages/grafana-e2e/src/support/selector.ts
new file mode 100644
index 00000000000..54aa938e607
--- /dev/null
+++ b/packages/grafana-e2e/src/support/selector.ts
@@ -0,0 +1,9 @@
+export interface SelectorApi {
+ fromAriaLabel: (selector: string) => string;
+ fromSelector: (selector: string) => string;
+}
+
+export const Selector: SelectorApi = {
+ fromAriaLabel: (selector: string) => `[aria-label="${selector}"]`,
+ fromSelector: (selector: string) => selector,
+};
diff --git a/packages/grafana-e2e/src/support/types.ts b/packages/grafana-e2e/src/support/types.ts
new file mode 100644
index 00000000000..e932e2eb075
--- /dev/null
+++ b/packages/grafana-e2e/src/support/types.ts
@@ -0,0 +1,41 @@
+import { Selector } from './selector';
+import { Url } from './url';
+import { e2e } from '../index';
+import { SelectorFunction, SelectorObject } from '../noTypeCheck';
+
+export type Selectors = Record;
+export type PageObjects = { [P in keyof S]: SelectorFunction };
+export type PageFactory = PageObjects & SelectorObject;
+export interface PageFactoryArgs {
+ url?: string;
+ selectors: S;
+}
+
+export const pageFactory = ({ url, selectors }: PageFactoryArgs): PageFactory => {
+ const visit = () => e2e().visit(Url.fromBaseUrl(url));
+ const pageObjects: PageObjects = {} as PageObjects;
+ const keys = Object.keys(selectors);
+
+ keys.forEach(key => {
+ const value = selectors[key];
+ if (typeof value === 'string') {
+ // @ts-ignore
+ pageObjects[key] = () => e2e().get(Selector.fromAriaLabel(value));
+ }
+ if (typeof value === 'function') {
+ // @ts-ignore
+ pageObjects[key] = (text?: string) => {
+ if (!text) {
+ return e2e().get(value());
+ }
+ return e2e().get(Selector.fromAriaLabel(value(text)));
+ };
+ }
+ });
+
+ return {
+ visit,
+ ...pageObjects,
+ selectors,
+ };
+};
diff --git a/packages/grafana-e2e/src/support/url.ts b/packages/grafana-e2e/src/support/url.ts
new file mode 100644
index 00000000000..69bd1705cb0
--- /dev/null
+++ b/packages/grafana-e2e/src/support/url.ts
@@ -0,0 +1,25 @@
+import { e2e } from '../index';
+
+export interface UrlApi {
+ fromBaseUrl: (url: string | undefined) => string;
+ getDashboardUid: (url: string) => string;
+}
+
+const uidRegex = '\\/d\\/(.*)\\/';
+const getBaseUrl = () => e2e.env('BASE_URL') || e2e.config().baseUrl || 'http://localhost:3000';
+
+export const Url: UrlApi = {
+ fromBaseUrl: (url: string | undefined) => {
+ url = url || '';
+ const strippedUrl = url.replace('^/', '');
+ return `${getBaseUrl()}${strippedUrl}`;
+ },
+ getDashboardUid: (url: string) => {
+ const matches = url.match(uidRegex);
+ if (!matches) {
+ throw new Error(`Couldn't parse uid from ${url}`);
+ }
+
+ return matches[1];
+ },
+};
diff --git a/packages/grafana-e2e/tsconfig.build.json b/packages/grafana-e2e/tsconfig.build.json
new file mode 100644
index 00000000000..34e37b5d0b8
--- /dev/null
+++ b/packages/grafana-e2e/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.test.tsx"]
+}
diff --git a/packages/grafana-e2e/tsconfig.json b/packages/grafana-e2e/tsconfig.json
new file mode 100644
index 00000000000..2cda6b40119
--- /dev/null
+++ b/packages/grafana-e2e/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../tsconfig.json",
+ "include": ["src/**/*.ts"],
+ "exclude": ["dist", "node_modules"],
+ "compilerOptions": {
+ "types": ["cypress"],
+ "rootDirs": ["."],
+ "typeRoots": ["./node_modules/@types", "types"],
+ "declarationDir": "dist",
+ "outDir": "compiled"
+ }
+}
diff --git a/packages/grafana-e2e/tslint.json b/packages/grafana-e2e/tslint.json
new file mode 100644
index 00000000000..8e2f21b3624
--- /dev/null
+++ b/packages/grafana-e2e/tslint.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tslint.json",
+ "rules": {}
+}
diff --git a/public/app/core/components/Login/LoginForm.tsx b/public/app/core/components/Login/LoginForm.tsx
index 03f960cedd4..c65573193c3 100644
--- a/public/app/core/components/Login/LoginForm.tsx
+++ b/public/app/core/components/Login/LoginForm.tsx
@@ -1,4 +1,6 @@
-import React, { PureComponent, SyntheticEvent, ChangeEvent } from 'react';
+import React, { ChangeEvent, PureComponent, SyntheticEvent } from 'react';
+import { e2e } from '@grafana/e2e';
+
import { FormModel } from './LoginCtrl';
interface Props {
@@ -72,7 +74,7 @@ export class LoginForm extends PureComponent {
className="gf-form-input login-form-input"
required
placeholder={this.props.loginHint}
- aria-label="Username input field"
+ aria-label={e2e.pages.Login.selectors.username}
onChange={this.onChangeUsername}
/>
@@ -85,7 +87,7 @@ export class LoginForm extends PureComponent {
ng-model="formModel.password"
id="inputPassword"
placeholder={this.props.passwordHint}
- aria-label="Password input field"
+ aria-label={e2e.pages.Login.selectors.password}
onChange={this.onChangePassword}
/>
@@ -93,7 +95,7 @@ export class LoginForm extends PureComponent {
{!this.props.isLoggingIn ? (