Commit d62926b5 by Steven Vachon Committed by GitHub

@grafana/e2e: improvements (#25342)

* Minor changes

* Remove console.* logger plugin

... as it doesn't work in Electron

* Only open/close panel editor options and groups when state is inverted

... meaning, only open when closed and only close when open. This avoids unpredictable states, causing inconsistent results.

* Support for adding multiple datasources and dashboards

... and having them all auto-removed when tests are completed

* Avoid page errors when removing dashboards and datasources [keep?]

* Wait for chart data before saving panel

... so that everything is ready when returning to the dashboard
parent 5f767e2c
...@@ -6,6 +6,9 @@ export const Pages = { ...@@ -6,6 +6,9 @@ export const Pages = {
submit: 'Login button', submit: 'Login button',
skip: 'Skip change password button', skip: 'Skip change password button',
}, },
Home: {
url: '/',
},
DataSource: { DataSource: {
name: 'Data source settings page name input field', name: 'Data source settings page name input field',
delete: 'Data source settings page Delete button', delete: 'Data source settings page Delete button',
......
...@@ -2,21 +2,17 @@ const compareScreenshots = require('./compareScreenshots'); ...@@ -2,21 +2,17 @@ const compareScreenshots = require('./compareScreenshots');
const extendConfig = require('./extendConfig'); const extendConfig = require('./extendConfig');
const readProvisions = require('./readProvisions'); const readProvisions = require('./readProvisions');
const typescriptPreprocessor = require('./typescriptPreprocessor'); const typescriptPreprocessor = require('./typescriptPreprocessor');
const { install: installConsoleLogger } = require('cypress-log-to-output');
module.exports = (on, config) => { module.exports = (on, config) => {
on('file:preprocessor', typescriptPreprocessor); on('file:preprocessor', typescriptPreprocessor);
on('task', { compareScreenshots, readProvisions }); on('task', { compareScreenshots, readProvisions });
on('task', { on('task', {
// @todo remove
log({ message, optional }) { log({ message, optional }) {
optional ? console.log(message, optional) : console.log(message); optional ? console.log(message, optional) : console.log(message);
return null; return null;
}, },
}); });
installConsoleLogger(on);
// Always extend with this library's config and return for diffing // Always extend with this library's config and return for diffing
// @todo remove this when possible: https://github.com/cypress-io/cypress/issues/5674 // @todo remove this when possible: https://github.com/cypress-io/cypress/issues/5674
return extendConfig(config); return extendConfig(config);
......
...@@ -11,7 +11,6 @@ Cypress.Commands.add('compareScreenshots', (config: CompareScreenshotsConfig | s ...@@ -11,7 +11,6 @@ Cypress.Commands.add('compareScreenshots', (config: CompareScreenshotsConfig | s
}); });
}); });
// @todo remove
Cypress.Commands.add('logToConsole', (message: string, optional?: any) => { Cypress.Commands.add('logToConsole', (message: string, optional?: any) => {
cy.task('log', { message, optional }); cy.task('log', { message, optional });
}); });
......
...@@ -50,7 +50,6 @@ ...@@ -50,7 +50,6 @@
"blink-diff": "1.0.13", "blink-diff": "1.0.13",
"commander": "5.0.0", "commander": "5.0.0",
"cypress": "^4.7.0", "cypress": "^4.7.0",
"cypress-log-to-output": "^1.0.8",
"execa": "4.0.0", "execa": "4.0.0",
"resolve-as-bin": "2.1.0", "resolve-as-bin": "2.1.0",
"ts-loader": "6.2.1", "ts-loader": "6.2.1",
......
import { DeleteDashboardConfig } from './deleteDashboard';
import { e2e } from '../index'; import { e2e } from '../index';
import { getDashboardUid } from '../support/url'; import { getDashboardUid } from '../support/url';
export const addDashboard = () => { export interface AddDashboardConfig {
e2e().logToConsole('Adding dashboard'); title: string;
}
// @todo this actually returns type `Cypress.Chainable`
export const addDashboard = (config?: Partial<AddDashboardConfig>): any => {
const fullConfig = {
title: `e2e-${Date.now()}`,
...config,
} as AddDashboardConfig;
const { title } = fullConfig;
e2e().logToConsole('Adding dashboard with title:', title);
e2e.pages.AddDashboard.visit(); e2e.pages.AddDashboard.visit();
const dashboardTitle = e2e.flows.saveNewDashboard(); e2e.pages.Dashboard.Toolbar.toolbarItems('Save dashboard').click();
e2e().logToConsole('Added dashboard with title:', dashboardTitle);
e2e.pages.SaveDashboardAsModal.newName()
.clear()
.type(title);
e2e.pages.SaveDashboardAsModal.save().click();
e2e() e2e.flows.assertSuccessNotification();
e2e().logToConsole('Added dashboard with title:', title);
return e2e()
.url() .url()
.then((url: string) => { .then((url: string) => {
e2e.setScenarioContext({ const uid = getDashboardUid(url);
lastAddedDashboard: dashboardTitle,
lastAddedDashboardUid: getDashboardUid(url), e2e.getScenarioContext().then(({ addedDashboards }: any) => {
e2e.setScenarioContext({
addedDashboards: [...addedDashboards, { title, uid } as DeleteDashboardConfig],
});
});
// @todo remove `wrap` when possible
return e2e().wrap({
config: fullConfig,
uid,
}); });
}); });
}; };
import { DeleteDataSourceConfig } from './deleteDataSource';
import { e2e } from '../index'; import { e2e } from '../index';
import { fromBaseUrl, getDataSourceId } from '../support/url'; import { fromBaseUrl, getDataSourceId } from '../support/url';
import { setScenarioContext } from '../support/scenarioContext';
export interface AddDataSourceConfig { export interface AddDataSourceConfig {
checkHealth: boolean; checkHealth: boolean;
expectedAlertMessage: string | RegExp; expectedAlertMessage: string | RegExp;
form: Function; form: Function;
name: string; name: string;
type: string;
} }
const DEFAULT_ADD_DATA_SOURCE_CONFIG: AddDataSourceConfig = { // @todo this actually returns type `Cypress.Chainable`
checkHealth: false, export const addDataSource = (config?: Partial<AddDataSourceConfig>): any => {
expectedAlertMessage: 'Data source is working', const fullConfig = {
form: () => {}, checkHealth: false,
name: 'TestData DB', expectedAlertMessage: 'Data source is working',
}; form: () => {},
name: `e2e-${Date.now()}`,
type: 'TestData DB',
...config,
} as AddDataSourceConfig;
export const addDataSource = (config?: Partial<AddDataSourceConfig>): string => { const { checkHealth, expectedAlertMessage, form, name, type } = fullConfig;
const { checkHealth, expectedAlertMessage, form, name } = { ...DEFAULT_ADD_DATA_SOURCE_CONFIG, ...config };
e2e().logToConsole('Adding data source with name:', name); e2e().logToConsole('Adding data source with name:', name);
e2e.pages.AddDataSource.visit(); e2e.pages.AddDataSource.visit();
e2e.pages.AddDataSource.dataSourcePlugins(name) e2e.pages.AddDataSource.dataSourcePlugins(type)
.scrollIntoView() .scrollIntoView()
.should('be.visible') // prevents flakiness .should('be.visible') // prevents flakiness
.click(); .click();
const dataSourceName = `e2e-${Date.now()}`;
e2e.pages.DataSource.name().clear(); e2e.pages.DataSource.name().clear();
e2e.pages.DataSource.name().type(dataSourceName); e2e.pages.DataSource.name().type(name);
form(); form();
e2e.pages.DataSource.saveAndTest().click(); e2e.pages.DataSource.saveAndTest().click();
e2e.pages.DataSource.alert().should('exist'); e2e.pages.DataSource.alert().should('exist');
e2e.pages.DataSource.alertMessage().contains(expectedAlertMessage); // assertion e2e.pages.DataSource.alertMessage().contains(expectedAlertMessage); // assertion
e2e().logToConsole('Added data source with name:', dataSourceName); e2e().logToConsole('Added data source with name:', name);
if (checkHealth) { return e2e()
e2e() .url()
.url() .then((url: string) => {
.then((url: string) => { const id = getDataSourceId(url);
const dataSourceId = getDataSourceId(url);
setScenarioContext({ e2e.getScenarioContext().then(({ addedDataSources }: any) => {
lastAddedDataSource: dataSourceName, e2e.setScenarioContext({
lastAddedDataSourceId: dataSourceId, addedDataSources: [...addedDataSources, { id, name } as DeleteDataSourceConfig],
}); });
});
const healthUrl = fromBaseUrl(`/api/datasources/${dataSourceId}/health`); if (checkHealth) {
const healthUrl = fromBaseUrl(`/api/datasources/${id}/health`);
e2e().logToConsole(`Fetching ${healthUrl}`); e2e().logToConsole(`Fetching ${healthUrl}`);
e2e() e2e()
.request(healthUrl) .request(healthUrl)
.its('body') .its('body')
.should('have.property', 'status') .should('have.property', 'status')
.and('eq', 'OK'); .and('eq', 'OK');
}
// @todo remove `wrap` when possible
return e2e().wrap({
config: fullConfig,
id,
}); });
} else {
setScenarioContext({
lastAddedDataSource: dataSourceName,
}); });
}
return dataSourceName;
}; };
import { e2e } from '../index'; import { e2e } from '../index';
import { getLocalStorage, requireLocalStorage } from '../support/localStorage';
import { getScenarioContext } from '../support/scenarioContext'; import { getScenarioContext } from '../support/scenarioContext';
export interface AddPanelConfig { export interface AddPanelConfig {
...@@ -7,6 +8,7 @@ export interface AddPanelConfig { ...@@ -7,6 +8,7 @@ export interface AddPanelConfig {
queriesForm: (config: AddPanelConfig) => void; queriesForm: (config: AddPanelConfig) => void;
panelTitle: string; panelTitle: string;
visualizationName: string; visualizationName: string;
waitForChartData: boolean;
} }
// @todo this actually returns type `Cypress.Chainable` // @todo this actually returns type `Cypress.Chainable`
...@@ -18,6 +20,7 @@ export const addPanel = (config?: Partial<AddPanelConfig>): any => ...@@ -18,6 +20,7 @@ export const addPanel = (config?: Partial<AddPanelConfig>): any =>
panelTitle: `e2e-${Date.now()}`, panelTitle: `e2e-${Date.now()}`,
queriesForm: () => {}, queriesForm: () => {},
visualizationName: 'Table', visualizationName: 'Table',
waitForChartData: true,
...config, ...config,
} as AddPanelConfig; } as AddPanelConfig;
...@@ -31,42 +34,98 @@ export const addPanel = (config?: Partial<AddPanelConfig>): any => ...@@ -31,42 +34,98 @@ export const addPanel = (config?: Partial<AddPanelConfig>): any =>
.get('.ds-picker') .get('.ds-picker')
.click() .click()
.contains('[id^="react-select-"][id*="-option-"]', dataSourceName) .contains('[id^="react-select-"][id*="-option-"]', dataSourceName)
.scrollIntoView()
.click(); .click();
isOptionsOpen().then((isOpen: any) => {
if (!isOpen) {
toggleOptions();
}
});
openOptionsGroup('settings');
getOptionsGroup('settings') getOptionsGroup('settings')
.find('[value="Panel Title"]') .find('[value="Panel Title"]')
.scrollIntoView()
.clear() .clear()
.type(panelTitle); .type(panelTitle);
toggleOptionsGroup('settings'); closeOptionsGroup('settings');
toggleOptionsGroup('type'); openOptionsGroup('type');
e2e() e2e()
.get(`[aria-label="Plugin visualization item ${visualizationName}"]`) .get(`[aria-label="Plugin visualization item ${visualizationName}"]`)
.scrollIntoView() .scrollIntoView()
.click(); .click();
toggleOptionsGroup('type'); closeOptionsGroup('type');
e2e().server();
e2e()
.route('POST', '/api/ds/query')
.as('chartData');
queriesForm(fullConfig); queriesForm(fullConfig);
e2e().wait('@chartData');
// @todo enable when plugins have this implemented // @todo enable when plugins have this implemented
//e2e.components.QueryEditorRow.actionButton('Disable/enable query').click(); //e2e.components.QueryEditorRow.actionButton('Disable/enable query').click();
//e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content').contains('No data'); //e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content').contains('No data');
//e2e.components.QueryEditorRow.actionButton('Disable/enable query').click(); //e2e.components.QueryEditorRow.actionButton('Disable/enable query').click();
e2e.components.PanelEditor.OptionsPane.close().click(); isOptionsOpen().then((isOpen: any) => {
if (isOpen) {
toggleOptions();
}
});
e2e() e2e()
.get('button[title="Apply changes and go back to dashboard"]') .get('button[title="Apply changes and go back to dashboard"]')
.click(); .click();
// @todo remove `wrap` when possible // @todo remove `wrap` when possible
return e2e().wrap(fullConfig); return e2e().wrap({ config: fullConfig });
});
// @todo this actually returns type `Cypress.Chainable`
const closeOptionsGroup = (name: string): any =>
isOptionsGroupOpen(name).then((isOpen: any) => {
if (isOpen) {
toggleOptionsGroup(name);
}
}); });
const getOptionsGroup = (name: string) => e2e().get(`.options-group:has([aria-label="Options group Panel ${name}"])`); const getOptionsGroup = (name: string) => e2e().get(`.options-group:has([aria-label="Options group Panel ${name}"])`);
// @todo this actually returns type `Cypress.Chainable`
const isOptionsGroupOpen = (name: string): any =>
requireLocalStorage(`grafana.dashboard.editor.ui.optionGroup[Panel ${name}]`).then(({ defaultToClosed }: any) => {
// @todo remove `wrap` when possible
return e2e().wrap(!defaultToClosed);
});
// @todo this actually returns type `Cypress.Chainable`
const isOptionsOpen = (): any =>
getLocalStorage('grafana.dashboard.editor.ui').then((data: any) => {
if (data) {
// @todo remove `wrap` when possible
return e2e().wrap(data.isPanelOptionsVisible);
} else {
// @todo remove `wrap` when possible
return e2e().wrap(true);
}
});
// @todo this actually returns type `Cypress.Chainable`
const openOptionsGroup = (name: string): any =>
isOptionsGroupOpen(name).then((isOpen: any) => {
if (!isOpen) {
toggleOptionsGroup(name);
}
});
const toggleOptions = () => e2e.components.PanelEditor.OptionsPane.close().click();
const toggleOptionsGroup = (name: string) => const toggleOptionsGroup = (name: string) =>
getOptionsGroup(name) getOptionsGroup(name)
.find('.editor-options-group-toggle') .find('.editor-options-group-toggle')
.scrollIntoView()
.click(); .click();
import { e2e } from '../index'; import { e2e } from '../index';
import { fromBaseUrl } from '../support/url'; import { fromBaseUrl } from '../support/url';
export const deleteDashboard = (dashBoardUid: string) => { export interface DeleteDashboardConfig {
e2e().logToConsole('Deleting dashboard with uid:', dashBoardUid); title: string;
e2e().request('DELETE', fromBaseUrl(`/api/dashboards/uid/${dashBoardUid}`)); uid: string;
}
export const deleteDashboard = ({ title, uid }: DeleteDashboardConfig) => {
e2e().logToConsole('Deleting dashboard with uid:', uid);
// Avoid dashboard page errors
e2e.pages.Home.visit();
e2e().request('DELETE', fromBaseUrl(`/api/dashboards/uid/${uid}`));
/* https://github.com/cypress-io/cypress/issues/2831 /* https://github.com/cypress-io/cypress/issues/2831
Flows.openDashboard(dashboardName); Flows.openDashboard(title);
Pages.Dashboard.settings().click(); Pages.Dashboard.settings().click();
...@@ -20,9 +28,17 @@ export const deleteDashboard = (dashBoardUid: string) => { ...@@ -20,9 +28,17 @@ export const deleteDashboard = (dashBoardUid: string) => {
Pages.Dashboards.dashboards().each(item => { Pages.Dashboards.dashboards().each(item => {
const text = item.text(); const text = item.text();
Cypress.log({ message: [text] }); Cypress.log({ message: [text] });
if (text && text.indexOf(dashboardName) !== -1) { if (text && text.indexOf(title) !== -1) {
expect(false).equals(true, `Dashboard ${dashboardName} was found although it was deleted.`); expect(false).equals(true, `Dashboard ${title} was found although it was deleted.`);
} }
}); });
*/ */
e2e.getScenarioContext().then(({ addedDashboards }: any) => {
e2e.setScenarioContext({
addedDashboards: addedDashboards.filter((dashboard: DeleteDashboardConfig) => {
return dashboard.title !== title && dashboard.uid !== uid;
}),
});
});
}; };
import { e2e } from '../index'; import { e2e } from '../index';
import { fromBaseUrl } from '../support/url'; import { fromBaseUrl } from '../support/url';
export const deleteDataSource = (dataSourceName: string) => { export interface DeleteDataSourceConfig {
e2e().logToConsole('Deleting data source with name:', dataSourceName); id: string;
e2e().request('DELETE', fromBaseUrl(`/api/datasources/name/${dataSourceName}`)); name: string;
}
export const deleteDataSource = ({ id, name }: DeleteDataSourceConfig) => {
e2e().logToConsole('Deleting data source with name:', name);
// Avoid datasources page errors
e2e.pages.Home.visit();
e2e().request('DELETE', fromBaseUrl(`/api/datasources/name/${name}`));
/* https://github.com/cypress-io/cypress/issues/2831 /* https://github.com/cypress-io/cypress/issues/2831
Pages.DataSources.visit(); Pages.DataSources.visit();
Pages.DataSources.dataSources(dataSourceName).click(); Pages.DataSources.dataSources(name).click();
Pages.DataSource.delete().click(); Pages.DataSource.delete().click();
...@@ -16,9 +24,17 @@ export const deleteDataSource = (dataSourceName: string) => { ...@@ -16,9 +24,17 @@ export const deleteDataSource = (dataSourceName: string) => {
Pages.DataSources.visit(); Pages.DataSources.visit();
Pages.DataSources.dataSources().each(item => { Pages.DataSources.dataSources().each(item => {
const text = item.text(); const text = item.text();
if (text && text.indexOf(dataSourceName) !== -1) { if (text && text.indexOf(name) !== -1) {
expect(false).equals(true, `Data source ${dataSourceName} was found although it was deleted.`); expect(false).equals(true, `Data source ${name} was found although it was deleted.`);
} }
}); });
*/ */
e2e.getScenarioContext().then(({ addedDataSources }: any) => {
e2e.setScenarioContext({
addedDataSources: addedDataSources.filter((dataSource: DeleteDataSourceConfig) => {
return dataSource.id !== id && dataSource.name !== name;
}),
});
});
}; };
...@@ -7,7 +7,6 @@ import { deleteDataSource } from './deleteDataSource'; ...@@ -7,7 +7,6 @@ import { deleteDataSource } from './deleteDataSource';
import { login } from './login'; import { login } from './login';
import { openDashboard } from './openDashboard'; import { openDashboard } from './openDashboard';
import { saveDashboard } from './saveDashboard'; import { saveDashboard } from './saveDashboard';
import { saveNewDashboard } from './saveNewDashboard';
import { openPanelMenuItem, PanelMenuItems } from './openPanelMenuItem'; import { openPanelMenuItem, PanelMenuItems } from './openPanelMenuItem';
export const Flows = { export const Flows = {
...@@ -20,7 +19,6 @@ export const Flows = { ...@@ -20,7 +19,6 @@ export const Flows = {
login, login,
openDashboard, openDashboard,
saveDashboard, saveDashboard,
saveNewDashboard,
openPanelMenuItem, openPanelMenuItem,
PanelMenuItems, PanelMenuItems,
}; };
import { e2e } from '../index';
export const saveNewDashboard = () => {
e2e.pages.Dashboard.Toolbar.toolbarItems('Save dashboard').click();
const dashboardTitle = `e2e-${Date.now()}`;
e2e.pages.SaveDashboardAsModal.newName().clear();
e2e.pages.SaveDashboardAsModal.newName().type(dashboardTitle);
e2e.pages.SaveDashboardAsModal.save().click();
e2e.flows.assertSuccessNotification();
return dashboardTitle;
};
export * from './types'; export * from './localStorage';
export * from './selector';
export * from './scenarioContext'; export * from './scenarioContext';
export * from './selector';
export * from './types';
import { e2e } from '../index';
// @todo this actually returns type `Cypress.Chainable`
const get = (key: string): any =>
e2e()
.wrap({ getLocalStorage: () => localStorage.getItem(key) })
.invoke('getLocalStorage');
// @todo this actually returns type `Cypress.Chainable`
export const getLocalStorage = (key: string): any =>
get(key).then((value: any) => {
if (value === null) {
return value;
} else {
return JSON.parse(value);
}
});
// @todo this actually returns type `Cypress.Chainable`
export const requireLocalStorage = (key: string): any =>
get(key) // `getLocalStorage()` would turn 'null' into `null`
.should('not.equal', null)
.then((value: any) => JSON.parse(value as string));
...@@ -34,14 +34,9 @@ export const e2eScenario = ({ ...@@ -34,14 +34,9 @@ export const e2eScenario = ({
}); });
afterEach(() => { afterEach(() => {
getScenarioContext().then(({ lastAddedDashboardUid, lastAddedDataSource }: any) => { getScenarioContext().then(({ addedDashboards, addedDataSources }: any) => {
if (lastAddedDashboardUid) { addedDashboards.forEach((dashboard: any) => Flows.deleteDashboard(dashboard));
Flows.deleteDashboard(lastAddedDashboardUid); addedDataSources.forEach((dataSource: any) => Flows.deleteDataSource(dataSource));
}
if (lastAddedDataSource) {
Flows.deleteDataSource(lastAddedDataSource);
}
}); });
}); });
......
import { e2e } from '../index'; import { e2e } from '../index';
import { DeleteDashboardConfig } from '../flows/deleteDashboard';
import { DeleteDataSourceConfig } from '../flows/deleteDataSource';
export interface ScenarioContext { export interface ScenarioContext {
lastAddedDashboard: string; addedDashboards: DeleteDashboardConfig[];
addedDataSources: DeleteDataSourceConfig[];
lastAddedDashboard: string; // @todo rename to `lastAddedDashboardTitle`
lastAddedDashboardUid: string; lastAddedDashboardUid: string;
lastAddedDataSource: string; lastAddedDataSource: string; // @todo rename to `lastAddedDataSourceName`
lastAddedDataSourceId: string; lastAddedDataSourceId: string;
[key: string]: any; [key: string]: any;
} }
const scenarioContext: ScenarioContext = { const scenarioContext: ScenarioContext = {
lastAddedDashboard: '', addedDashboards: [],
lastAddedDashboardUid: '', addedDataSources: [],
lastAddedDataSource: '', get lastAddedDashboard() {
lastAddedDataSourceId: '', return lastProperty(this.addedDashboards, 'title');
},
get lastAddedDashboardUid() {
return lastProperty(this.addedDashboards, 'uid');
},
get lastAddedDataSource() {
return lastProperty(this.addedDataSources, 'name');
},
get lastAddedDataSourceId() {
return lastProperty(this.addedDataSources, 'id');
},
}; };
const lastProperty = <T extends DeleteDashboardConfig | DeleteDataSourceConfig, K extends keyof T>(
items: T[],
key: K
) => items[items.length - 1]?.[key] ?? '';
// @todo this actually returns type `Cypress.Chainable` // @todo this actually returns type `Cypress.Chainable`
export const getScenarioContext = (): any => export const getScenarioContext = (): any =>
e2e() e2e()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment