Commit a4308fff by Steven Vachon Committed by GitHub

@grafana/e2e: API improvements (#23079)

* Minor changes

* Fixtures path is now relative to the project directory

* URL support module now has individual exports

* Scenario context timing issues resolved

... caused by being ran synchronously, instead of as part of Cypress' asynchronous queue.

* Scenario context API now supports multiple keys per function call

* addDataSource flow accepts a config argument

… and optionally checks datasource health status

* Added readProvisions command

* Added addPanel flow
parent 13ab84f2
...@@ -190,13 +190,17 @@ const assertAdding3dependantQueryVariablesScenario = (queryVariables: QueryVaria ...@@ -190,13 +190,17 @@ const assertAdding3dependantQueryVariablesScenario = (queryVariables: QueryVaria
for (let queryVariableIndex = 0; queryVariableIndex < queryVariables.length; queryVariableIndex++) { for (let queryVariableIndex = 0; queryVariableIndex < queryVariables.length; queryVariableIndex++) {
const { name, label, query, options, selectedOption } = queryVariables[queryVariableIndex]; const { name, label, query, options, selectedOption } = queryVariables[queryVariableIndex];
const asserts = queryVariables.slice(0, queryVariableIndex + 1); const asserts = queryVariables.slice(0, queryVariableIndex + 1);
createQueryVariable({ // @todo remove `@ts-ignore` when possible
dataSourceName: e2e.context().get('lastAddedDataSource'), // @ts-ignore
name, e2e.getScenarioContext().then(({ lastAddedDataSource }) => {
label, createQueryVariable({
query, dataSourceName: lastAddedDataSource,
options, name,
selectedOption, label,
query,
options,
selectedOption,
});
}); });
assertVariableTable(asserts); assertVariableTable(asserts);
...@@ -565,7 +569,11 @@ e2e.scenario({ ...@@ -565,7 +569,11 @@ e2e.scenario({
addScenarioDashBoard: true, addScenarioDashBoard: true,
skipScenario: false, skipScenario: false,
scenario: () => { scenario: () => {
e2e.flows.openDashboard(e2e.context().get('lastAddedDashboardUid')); // @todo remove `@ts-ignore` when possible
// @ts-ignore
e2e.getScenarioContext().then(({ lastAddedDashboardUid }) => {
e2e.flows.openDashboard(lastAddedDashboardUid);
});
e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').click(); e2e.pages.Dashboard.Toolbar.toolbarItems('Dashboard settings').click();
e2e.pages.Dashboard.Settings.General.sectionItems('Variables').click(); e2e.pages.Dashboard.Settings.General.sectionItems('Variables').click();
e2e.pages.Dashboard.Settings.Variables.List.addVariableCTA().click(); e2e.pages.Dashboard.Settings.Variables.List.addVariableCTA().click();
......
...@@ -7,7 +7,11 @@ e2e.scenario({ ...@@ -7,7 +7,11 @@ e2e.scenario({
addScenarioDashBoard: true, addScenarioDashBoard: true,
skipScenario: false, skipScenario: false,
scenario: () => { scenario: () => {
e2e.flows.openDashboard(e2e.context().get('lastAddedDashboardUid')); // @todo remove `@ts-ignore` when possible
// @ts-ignore
e2e.getScenarioContext().then(({ lastAddedDashboardUid }) => {
e2e.flows.openDashboard(lastAddedDashboardUid);
});
e2e.pages.Dashboard.Toolbar.toolbarItems('Add panel').click(); e2e.pages.Dashboard.Toolbar.toolbarItems('Add panel').click();
e2e.pages.AddDashboard.ctaButtons('Add Query').click(); e2e.pages.AddDashboard.ctaButtons('Add Query').click();
......
...@@ -12,6 +12,7 @@ module.exports = async baseConfig => { ...@@ -12,6 +12,7 @@ module.exports = async baseConfig => {
if (CWD) { if (CWD) {
const projectConfig = { const projectConfig = {
fixturesFolder: `${CWD}/cypress/fixtures`,
integrationFolder: `${CWD}/cypress/integration`, integrationFolder: `${CWD}/cypress/integration`,
screenshotsFolder: `${CWD}/cypress/screenshots`, screenshotsFolder: `${CWD}/cypress/screenshots`,
videosFolder: `${CWD}/cypress/videos`, videosFolder: `${CWD}/cypress/videos`,
......
const compareSnapshotsPlugin = require('./compareSnapshots'); const compareSnapshotsPlugin = require('./compareSnapshots');
const extendConfig = require('./extendConfig'); const extendConfig = require('./extendConfig');
const readProvisions = require('./readProvisions');
const typescriptPreprocessor = require('./typescriptPreprocessor'); const typescriptPreprocessor = require('./typescriptPreprocessor');
module.exports = (on, config) => { module.exports = (on, config) => {
...@@ -10,7 +11,7 @@ module.exports = (on, config) => { ...@@ -10,7 +11,7 @@ module.exports = (on, config) => {
// failed: require('cypress-failed-log/src/failed')(), // failed: require('cypress-failed-log/src/failed')(),
// }); // });
on('file:preprocessor', typescriptPreprocessor); on('file:preprocessor', typescriptPreprocessor);
on('task', { compareSnapshotsPlugin }); on('task', { compareSnapshotsPlugin, readProvisions });
on('task', { on('task', {
log({ message, optional }) { log({ message, optional }) {
optional ? console.log(message, optional) : console.log(message); optional ? console.log(message, optional) : console.log(message);
......
'use strict';
const { parse: parseYml } = require('yaml');
const {
promises: { readFile },
} = require('fs');
const { resolve: resolvePath } = require('path');
const readProvision = filePath => readFile(filePath, 'utf8').then(contents => parseYml(contents));
const readProvisions = filePaths => Promise.all(filePaths.map(readProvision));
// Paths are relative to <project-root>/provisioning
module.exports = ({ CWD, filePaths }) =>
readProvisions(filePaths.map(filePath => resolvePath(CWD, 'provisioning', filePath)));
...@@ -25,3 +25,10 @@ Cypress.Commands.add('compareSnapshot', (args: CompareSnapshotArgs) => { ...@@ -25,3 +25,10 @@ Cypress.Commands.add('compareSnapshot', (args: CompareSnapshotArgs) => {
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 });
}); });
Cypress.Commands.add('readProvisions', (filePaths: string[]) => {
cy.task('readProvisions', {
CWD: Cypress.env('CWD'),
filePaths,
});
});
...@@ -4,5 +4,6 @@ declare namespace Cypress { ...@@ -4,5 +4,6 @@ declare namespace Cypress {
interface Chainable { interface Chainable {
compareSnapshot(args: CompareSnapshotArgs): void; compareSnapshot(args: CompareSnapshotArgs): void;
logToConsole(message: string, optional?: any): void; logToConsole(message: string, optional?: any): void;
readProvisions(filePaths: string[]): Chainable;
} }
} }
...@@ -55,6 +55,7 @@ ...@@ -55,6 +55,7 @@
"cypress": "3.7.0", "cypress": "3.7.0",
"execa": "4.0.0", "execa": "4.0.0",
"ts-loader": "6.2.1", "ts-loader": "6.2.1",
"typescript": "3.7.2" "typescript": "3.7.2",
"yaml": "^1.8.3"
} }
} }
import { e2e } from '../index'; import { e2e } from '../index';
import { Url } from '../support/url'; import { getDashboardUid } from '../support/url';
export const addDashboard = () => { export const addDashboard = () => {
e2e().logToConsole('Adding dashboard'); e2e().logToConsole('Adding dashboard');
...@@ -11,7 +11,9 @@ export const addDashboard = () => { ...@@ -11,7 +11,9 @@ export const addDashboard = () => {
e2e() e2e()
.url() .url()
.then((url: string) => { .then((url: string) => {
e2e.context().set('lastAddedDashboard', dashboardTitle); e2e.setScenarioContext({
e2e.context().set('lastAddedDashboardUid', Url.getDashboardUid(url)); lastAddedDashboard: dashboardTitle,
lastAddedDashboardUid: getDashboardUid(url),
});
}); });
}; };
import { e2e } from '../index'; import { e2e } from '../index';
import { fromBaseUrl, getDataSourceId } from '../support/url';
import { setScenarioContext } from '../support/scenarioContext';
export const addDataSource = (pluginName?: string): string => { export interface AddDataSourceConfig {
pluginName = pluginName || 'TestData DB'; checkHealth: boolean;
e2e().logToConsole('Adding data source with pluginName:', pluginName); expectedAlertMessage: string;
form: Function;
name: string;
}
const DEFAULT_ADD_DATA_SOURCE_CONFIG: AddDataSourceConfig = {
checkHealth: false,
expectedAlertMessage: 'Data source is working',
form: () => {},
name: 'TestData DB',
};
export const addDataSource = (config?: Partial<AddDataSourceConfig>): string => {
const { checkHealth, expectedAlertMessage, form, name } = { ...DEFAULT_ADD_DATA_SOURCE_CONFIG, ...config };
e2e().logToConsole('Adding data source with name:', name);
e2e.pages.AddDataSource.visit(); e2e.pages.AddDataSource.visit();
e2e.pages.AddDataSource.dataSourcePlugins(pluginName) e2e.pages.AddDataSource.dataSourcePlugins(name)
.scrollIntoView() .scrollIntoView()
.should('be.visible') // prevents flakiness .should('be.visible') // prevents flakiness
.click(); .click();
const dataSourceName = `e2e-${new Date().getTime()}`; 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(dataSourceName);
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().should('contain.text', 'Data source is working'); e2e.pages.DataSource.alertMessage().should('contain.text', expectedAlertMessage);
e2e().logToConsole('Added data source with name:', dataSourceName); e2e().logToConsole('Added data source with name:', dataSourceName);
e2e.context().set('lastAddedDataSource', dataSourceName);
if (checkHealth) {
e2e()
.url()
.then((url: string) => {
const dataSourceId = getDataSourceId(url);
setScenarioContext({
lastAddedDataSource: dataSourceName,
lastAddedDataSourceId: dataSourceId,
});
const healthUrl = fromBaseUrl(`/api/datasources/${dataSourceId}/health`);
e2e().logToConsole(`Fetching ${healthUrl}`);
e2e()
.request(healthUrl)
.its('body')
.should('have.property', 'status')
.and('eq', 'OK');
});
} else {
setScenarioContext({
lastAddedDataSource: dataSourceName,
});
}
return dataSourceName; return dataSourceName;
}; };
import { e2e } from '../index';
import { getScenarioContext } from '../support/scenarioContext';
export interface AddPanelConfig {
dataSourceName: string;
queriesForm: Function;
}
const DEFAULT_ADD_PANEL_CONFIG: AddPanelConfig = {
dataSourceName: 'TestData DB',
queriesForm: () => {},
};
export const addPanel = (config?: Partial<AddPanelConfig>) => {
const { dataSourceName, queriesForm } = { ...DEFAULT_ADD_PANEL_CONFIG, ...config };
// @todo remove `@ts-ignore` when possible
// @ts-ignore
getScenarioContext().then(({ lastAddedDashboardUid }) => {
e2e.flows.openDashboard(lastAddedDashboardUid);
e2e.pages.Dashboard.Toolbar.toolbarItems('Add panel').click();
e2e.pages.AddDashboard.ctaButtons('Add Query').click();
e2e()
.get('.ds-picker')
.click()
.contains(dataSourceName)
.click();
queriesForm();
});
};
import { Url } from '../support/url';
import { e2e } from '../index'; import { e2e } from '../index';
import { fromBaseUrl } from '../support/url';
export const deleteDashboard = (dashBoardUid: string) => { export const deleteDashboard = (dashBoardUid: string) => {
e2e().logToConsole('Deleting dashboard with uid:', dashBoardUid); e2e().logToConsole('Deleting dashboard with uid:', dashBoardUid);
e2e().request('DELETE', Url.fromBaseUrl(`/api/dashboards/uid/${dashBoardUid}`)); e2e().request('DELETE', fromBaseUrl(`/api/dashboards/uid/${dashBoardUid}`));
/* https://github.com/cypress-io/cypress/issues/2831 /* https://github.com/cypress-io/cypress/issues/2831
Flows.openDashboard(dashboardName); Flows.openDashboard(dashboardName);
......
import { Url } from '../support/url';
import { e2e } from '../index'; import { e2e } from '../index';
import { fromBaseUrl } from '../support/url';
export const deleteDataSource = (dataSourceName: string) => { export const deleteDataSource = (dataSourceName: string) => {
e2e().logToConsole('Deleting data source with name:', dataSourceName); e2e().logToConsole('Deleting data source with name:', dataSourceName);
e2e().request('DELETE', Url.fromBaseUrl(`/api/datasources/name/${dataSourceName}`)); e2e().request('DELETE', fromBaseUrl(`/api/datasources/name/${dataSourceName}`));
/* https://github.com/cypress-io/cypress/issues/2831 /* https://github.com/cypress-io/cypress/issues/2831
Pages.DataSources.visit(); Pages.DataSources.visit();
......
import { login } from './login';
import { addDataSource } from './addDataSource';
import { deleteDataSource } from './deleteDataSource';
import { addDashboard } from './addDashboard'; import { addDashboard } from './addDashboard';
import { addDataSource } from './addDataSource';
import { addPanel } from './addPanel';
import { assertSuccessNotification } from './assertSuccessNotification'; import { assertSuccessNotification } from './assertSuccessNotification';
import { deleteDashboard } from './deleteDashboard'; import { deleteDashboard } from './deleteDashboard';
import { deleteDataSource } from './deleteDataSource';
import { login } from './login';
import { openDashboard } from './openDashboard'; import { openDashboard } from './openDashboard';
import { saveNewDashboard } from './saveNewDashboard';
import { saveDashboard } from './saveDashboard'; import { saveDashboard } from './saveDashboard';
import { saveNewDashboard } from './saveNewDashboard';
export const Flows = { export const Flows = {
login,
addDataSource,
deleteDataSource,
addDashboard, addDashboard,
addDataSource,
addPanel,
assertSuccessNotification, assertSuccessNotification,
deleteDashboard, deleteDashboard,
deleteDataSource,
login,
openDashboard, openDashboard,
saveNewDashboard,
saveDashboard, saveDashboard,
saveNewDashboard,
}; };
...@@ -3,7 +3,7 @@ import { e2e } from '../index'; ...@@ -3,7 +3,7 @@ import { e2e } from '../index';
export const saveNewDashboard = () => { export const saveNewDashboard = () => {
e2e.pages.Dashboard.Toolbar.toolbarItems('Save dashboard').click(); e2e.pages.Dashboard.Toolbar.toolbarItems('Save dashboard').click();
const dashboardTitle = `e2e-${new Date().getTime()}`; const dashboardTitle = `e2e-${Date.now()}`;
e2e.pages.SaveDashboardAsModal.newName().clear(); e2e.pages.SaveDashboardAsModal.newName().clear();
e2e.pages.SaveDashboardAsModal.newName().type(dashboardTitle); e2e.pages.SaveDashboardAsModal.newName().type(dashboardTitle);
e2e.pages.SaveDashboardAsModal.save().click(); e2e.pages.SaveDashboardAsModal.save().click();
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
import { e2eScenario, ScenarioArguments } from './support/scenario'; import { e2eScenario, ScenarioArguments } from './support/scenario';
import { Pages } from './pages'; import { Pages } from './pages';
import { Flows } from './flows'; import { Flows } from './flows';
import { scenarioContext } from './support/scenarioContext'; import { getScenarioContext, setScenarioContext } from './support/scenarioContext';
export type SelectorFunction = (text?: string) => Cypress.Chainable<JQuery<HTMLElement>>; export type SelectorFunction = (text?: string) => Cypress.Chainable<JQuery<HTMLElement>>;
export type SelectorObject<S> = { export type SelectorObject<S> = {
...@@ -20,9 +20,10 @@ const e2eObject = { ...@@ -20,9 +20,10 @@ const e2eObject = {
blobToBase64String: (blob: any) => Cypress.Blob.blobToBase64String(blob), blobToBase64String: (blob: any) => Cypress.Blob.blobToBase64String(blob),
imgSrcToBlob: (url: string) => Cypress.Blob.imgSrcToBlob(url), imgSrcToBlob: (url: string) => Cypress.Blob.imgSrcToBlob(url),
scenario: (args: ScenarioArguments) => e2eScenario(args), scenario: (args: ScenarioArguments) => e2eScenario(args),
context: scenarioContext,
pages: Pages, pages: Pages,
flows: Flows, flows: Flows,
getScenarioContext,
setScenarioContext,
}; };
export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject); export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject);
...@@ -3,7 +3,7 @@ import { e2e } from '../index'; ...@@ -3,7 +3,7 @@ import { e2e } from '../index';
export interface ScenarioArguments { export interface ScenarioArguments {
describeName: string; describeName: string;
itName: string; itName: string;
scenario: () => void; scenario: Function;
skipScenario?: boolean; skipScenario?: boolean;
addScenarioDataSource?: boolean; addScenarioDataSource?: boolean;
addScenarioDashBoard?: boolean; addScenarioDashBoard?: boolean;
...@@ -19,34 +19,33 @@ export const e2eScenario = ({ ...@@ -19,34 +19,33 @@ export const e2eScenario = ({
}: ScenarioArguments) => { }: ScenarioArguments) => {
describe(describeName, () => { describe(describeName, () => {
if (skipScenario) { if (skipScenario) {
it.skip(itName, () => { it.skip(itName, () => scenario());
// @ts-ignore yarn start in root throws error otherwise } else {
expect(false).equals(true); beforeEach(() => {
e2e.flows.login('admin', 'admin');
if (addScenarioDataSource) {
e2e.flows.addDataSource();
}
if (addScenarioDashBoard) {
e2e.flows.addDashboard();
}
}); });
return;
}
beforeEach(() => { afterEach(() => {
e2e.flows.login('admin', 'admin'); // @todo remove `@ts-ignore` when possible
if (addScenarioDataSource) { // @ts-ignore
e2e.flows.addDataSource('TestData DB'); e2e.getScenarioContext().then(({ lastAddedDashboardUid, lastAddedDataSource }) => {
} if (lastAddedDataSource) {
if (addScenarioDashBoard) { e2e.flows.deleteDataSource(lastAddedDataSource);
e2e.flows.addDashboard(); }
}
});
afterEach(() => { if (lastAddedDashboardUid) {
if (e2e.context().get('lastAddedDataSource')) { e2e.flows.deleteDashboard(lastAddedDashboardUid);
e2e.flows.deleteDataSource(e2e.context().get('lastAddedDataSource')); }
} });
if (e2e.context().get('lastAddedDashboardUid')) { });
e2e.flows.deleteDashboard(e2e.context().get('lastAddedDashboardUid'));
}
});
it(itName, () => { it(itName, () => scenario());
scenario(); }
});
}); });
}; };
import { e2e } from '../index';
export interface ScenarioContext { export interface ScenarioContext {
lastAddedDataSource: string;
lastAddedDashboard: string; lastAddedDashboard: string;
lastAddedDashboardUid: string; lastAddedDashboardUid: string;
lastAddedDataSource: string;
lastAddedDataSourceId: string;
[key: string]: any; [key: string]: any;
} }
const scenarioContexts: ScenarioContext = { const scenarioContext: ScenarioContext = {
lastAddedDataSource: '',
lastAddedDashboard: '', lastAddedDashboard: '',
lastAddedDashboardUid: '', lastAddedDashboardUid: '',
lastAddedDataSource: '',
lastAddedDataSourceId: '',
}; };
export interface ScenarioContextApi { // @todo this actually returns type `Cypress.Chainable`
get: <T>(name: string | keyof ScenarioContext) => T; export const getScenarioContext = (): any =>
set: <T>(name: string | keyof ScenarioContext, value: T) => void; e2e()
} .wrap({
getScenarioContext: () => ({ ...scenarioContext } as ScenarioContext),
export const scenarioContext = (): ScenarioContextApi => { })
const get = <T>(name: string | keyof ScenarioContext): T => scenarioContexts[name] as T; .invoke('getScenarioContext');
const set = <T>(name: string | keyof ScenarioContext, value: T): void => {
scenarioContexts[name] = value;
};
return { // @todo this actually returns type `Cypress.Chainable`
get, export const setScenarioContext = (newContext: Partial<ScenarioContext>): any =>
set, e2e()
}; .wrap({
}; setScenarioContext: () => {
Object.entries(newContext).forEach(([key, value]) => {
scenarioContext[key] = value;
});
},
})
.invoke('setScenarioContext');
import { Selector } from './selector'; import { Selector } from './selector';
import { Url } from './url'; import { fromBaseUrl } from './url';
import { e2e } from '../index'; import { e2e } from '../index';
import { SelectorFunction, SelectorObject } from '../noTypeCheck'; import { SelectorFunction, SelectorObject } from '../noTypeCheck';
...@@ -19,11 +19,11 @@ export const pageFactory = <S extends Selectors>({ url, selectors }: PageFactory ...@@ -19,11 +19,11 @@ export const pageFactory = <S extends Selectors>({ url, selectors }: PageFactory
let parsedUrl = ''; let parsedUrl = '';
if (typeof url === 'string') { if (typeof url === 'string') {
parsedUrl = Url.fromBaseUrl(url); parsedUrl = fromBaseUrl(url);
} }
if (typeof url === 'function' && args) { if (typeof url === 'function' && args) {
parsedUrl = Url.fromBaseUrl(url(args)); parsedUrl = fromBaseUrl(url(args));
} }
e2e().logToConsole('Visiting', parsedUrl); e2e().logToConsole('Visiting', parsedUrl);
......
import { e2e } from '../index'; 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'; const getBaseUrl = () => e2e.env('BASE_URL') || e2e.config().baseUrl || 'http://localhost:3000';
export const Url: UrlApi = { export const fromBaseUrl = (url = ''): string => {
fromBaseUrl: (url: string | undefined) => { const strippedUrl = url.replace('^/', '');
url = url || ''; return `${getBaseUrl()}${strippedUrl}`;
const strippedUrl = url.replace('^/', ''); };
return `${getBaseUrl()}${strippedUrl}`;
}, export const getDashboardUid = (url: string): string => {
getDashboardUid: (url: string) => { const matches = url.match(/\/d\/(.*)\//);
const matches = url.match(uidRegex); if (!matches) {
if (!matches) { throw new Error(`Couldn't parse uid from ${url}`);
throw new Error(`Couldn't parse uid from ${url}`); } else {
} return matches[1];
}
};
export const getDataSourceId = (url: string): string => {
const matches = url.match(/\/edit\/(.*)\//);
if (!matches) {
throw new Error(`Couldn't parse id from ${url}`);
} else {
return matches[1]; return matches[1];
}, }
}; };
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