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 ? ( diff --git a/public/app/features/dashboard/components/DashNav/DashNavButton.tsx b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx index 25b29a7a1cd..aaf4fc2acb1 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavButton.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavButton.tsx @@ -1,8 +1,8 @@ // Libraries import React, { FunctionComponent } from 'react'; - // Components import { Tooltip } from '@grafana/ui'; +import { e2e } from '@grafana/e2e'; interface Props { icon: string; @@ -19,7 +19,7 @@ export const DashNavButton: FunctionComponent = ({ icon, tooltip, classSu diff --git a/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts index 17047dbcf54..2583f361ec6 100644 --- a/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts +++ b/public/app/features/dashboard/components/DashboardSettings/SettingsCtrl.ts @@ -1,4 +1,4 @@ -import { coreModule, appEvents, contextSrv } from 'app/core/core'; +import { appEvents, contextSrv, coreModule } from 'app/core/core'; import { DashboardModel } from '../../state/DashboardModel'; import $ from 'jquery'; import _ from 'lodash'; @@ -9,6 +9,7 @@ import { DashboardSrv } from '../../services/DashboardSrv'; import { CoreEvents } from 'app/types'; import { GrafanaRootScope } from 'app/routes/GrafanaCtrl'; import { AppEvents } from '@grafana/data'; +import { e2e } from '@grafana/e2e'; export class SettingsCtrl { dashboard: DashboardModel; @@ -21,6 +22,7 @@ export class SettingsCtrl { canDelete: boolean; sections: any[]; hasUnsavedFolderChange: boolean; + selectors: typeof e2e.pages.DashboardSettings.selectors; /** @ngInject */ constructor( @@ -53,6 +55,7 @@ export class SettingsCtrl { this.$rootScope.onAppEvent(CoreEvents.routeUpdated, this.onRouteUpdated.bind(this), $scope); this.$rootScope.appEvent(CoreEvents.dashScroll, { animate: false, pos: 0 }); this.$rootScope.onAppEvent(CoreEvents.dashboardSaved, this.onPostSave.bind(this), $scope); + this.selectors = e2e.pages.DashboardSettings.selectors; } buildSectionList() { diff --git a/public/app/features/dashboard/components/DashboardSettings/template.html b/public/app/features/dashboard/components/DashboardSettings/template.html index b0275cee1ec..a357124dc67 100644 --- a/public/app/features/dashboard/components/DashboardSettings/template.html +++ b/public/app/features/dashboard/components/DashboardSettings/template.html @@ -1,5 +1,5 @@