e2e: Uses Cypress instead of Puppeteer (#20753)

* WIP: intial commit

* Tests: Runs e2e tests

* Refactor: Adds BASE_URL support

* Refactor: Adds namespacing

* Refactor: Cleans up the Page api

* Build: Adds to build-branches-and-prs job for testing

* Build: Hardcoded image for now

* Refactor: Uses Selectors in App

* Refactor: Adds addDataSource flow

* WIP

* Refactor: Adds e2eScenario

* Refactor: Adds add and delete scenarios

* Refactor: Adds logging

* Refactor: Adds ability to for Selectors with variables

* Refactor: Using variable selectors instead

* Refactor: Adds flow until Share Panel

* Refactor: Adds clicking on rendered image link

* Refactor: Deletes log output

* Refactor: Updates snapshots

* Chore: Reverts changes

* Refactor: Removes log plugin because maybe it breaks yarn build

* Refactor: Adds rendered image download

* Refactor: Adds image comparison

* Refactor: Removes uncaught errors override

* Refactor: Changes order of images to compare

* Refactor: Updates truth image

* Build: Updates path to CI artifacts

* Refactor: Cleaning up types and config

* wip

* Refactor: Cleans up external api

* Refactor: More cleanup

* Refactor: More cleanup

* Refactor: Removes usages of Pages and Flows

* Refactor: Removes last traces of Cypress in spec

* Refactor: Adds comments
This commit is contained in:
Hugo Häggmark
2019-12-09 00:14:25 -08:00
committed by GitHub
parent 10d36b282b
commit 58cffde0f2
91 changed files with 1955 additions and 95 deletions
+2
View File
@@ -0,0 +1,2 @@
+5
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"supportFile": "cypress/support/index.ts"
}
@@ -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;
@@ -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);
@@ -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
});
};
@@ -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);
}
});
});
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable {
compareSnapshot(args: CompareSnapshotArgs): void;
}
}
@@ -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);
});
});
}
}
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["cypress"]
},
"include": ["**/*.ts"]
}
+7
View File
@@ -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');
}
+41
View File
@@ -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"
}
+33
View File
@@ -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' })];
@@ -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),
});
});
});
};
@@ -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;
};
@@ -0,0 +1,7 @@
import { e2e } from '../index';
export const assertSuccessNotification = () => {
e2e()
.get('.alert-success')
.should('exist');
};
@@ -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.`);
}
});
*/
};
@@ -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.`);
}
});
*/
};
+21
View File
@@ -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,
};
+8
View File
@@ -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();
};
@@ -0,0 +1,6 @@
import { e2e } from '../index';
export const openDashboard = (dashboardTitle: string) => {
e2e.pages.Dashboards.visit();
e2e.pages.Dashboards.dashboards(dashboardTitle).click();
};
@@ -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();
};
@@ -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;
};
+3
View File
@@ -0,0 +1,3 @@
import { e2e } from './noTypeCheck';
export { e2e };
+26
View File
@@ -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<any>;
export type SelectorObject<S> = {
visit: () => Cypress.Chainable<any>;
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);
@@ -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}`,
},
});
@@ -0,0 +1,8 @@
import { pageFactory } from '../support';
export const AddDataSource = pageFactory({
url: '/datasources/new',
selectors: {
dataSourcePlugins: (pluginName: string) => `Data source plugin item ${pluginName}`,
},
});
@@ -0,0 +1,8 @@
import { pageFactory } from '../support';
export const ConfirmModal = pageFactory({
url: '',
selectors: {
delete: 'Confirm Modal Danger Button',
},
});
@@ -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',
},
});
@@ -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',
},
});
@@ -0,0 +1,8 @@
import { pageFactory } from '../support';
export const Dashboards = pageFactory({
url: '/dashboards',
selectors: {
dashboards: (title: string) => `Dashboard search item ${title}`,
},
});
@@ -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',
},
});
@@ -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',
},
});
@@ -0,0 +1,8 @@
import { pageFactory } from '../support';
export const EditPanel = pageFactory({
url: '',
selectors: {
tabItems: (text: string) => `Edit panel tab item ${text}`,
},
});
@@ -0,0 +1,5 @@
import { VisualizationTab } from './visualizationTab';
export const Graph = {
VisualizationTab,
};
@@ -0,0 +1,8 @@
import { pageFactory } from '../../support';
export const VisualizationTab = pageFactory({
url: '',
selectors: {
xAxisSection: 'X-Axis section',
},
});
+41
View File
@@ -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,
},
},
};
+10
View File
@@ -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',
},
});
+9
View File
@@ -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}`,
},
});
@@ -0,0 +1,9 @@
import { pageFactory } from '../support';
export const SaveDashboardAsModal = pageFactory({
url: '',
selectors: {
newName: 'Save dashboard title field',
save: 'Save dashboard button',
},
});
@@ -0,0 +1,8 @@
import { pageFactory } from '../support';
export const SaveDashboardModal = pageFactory({
url: '',
selectors: {
save: 'Dashboard settings Save Dashboard Modal Save button',
},
});
@@ -0,0 +1,8 @@
import { pageFactory } from '../support';
export const SharePanelModal = pageFactory({
url: '',
selectors: {
linkToRenderedImage: 'Link to rendered image',
},
});
+5
View File
@@ -0,0 +1,5 @@
import { QueryTab } from './queryTab';
export const TestData = {
QueryTab,
};
+8
View File
@@ -0,0 +1,8 @@
import { pageFactory } from '../../support';
export const QueryTab = pageFactory({
url: '',
selectors: {
scenarioSelect: 'Test Data Query scenario select',
},
});
@@ -0,0 +1,5 @@
import { ScenarioContext } from './scenario';
export { ScenarioContext };
export * from './types';
export * from './selector';
@@ -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,
});
});
});
};
@@ -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,
};
+41
View File
@@ -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<string, string | Function>;
export type PageObjects<S> = { [P in keyof S]: SelectorFunction };
export type PageFactory<S> = PageObjects<S> & SelectorObject<S>;
export interface PageFactoryArgs<S extends Selectors> {
url?: string;
selectors: S;
}
export const pageFactory = <S extends Selectors>({ url, selectors }: PageFactoryArgs<S>): PageFactory<S> => {
const visit = () => e2e().visit(Url.fromBaseUrl(url));
const pageObjects: PageObjects<S> = {} as PageObjects<S>;
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,
};
};
+25
View File
@@ -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];
},
};
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.test.tsx"]
}
+12
View File
@@ -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"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tslint.json",
"rules": {}
}