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
for (let queryVariableIndex = 0; queryVariableIndex < queryVariables.length; queryVariableIndex++) {
const { name, label, query, options, selectedOption } = queryVariables[queryVariableIndex];
const asserts = queryVariables.slice(0, queryVariableIndex + 1);
createQueryVariable({
dataSourceName: e2e.context().get('lastAddedDataSource'),
name,
label,
query,
options,
selectedOption,
// @todo remove `@ts-ignore` when possible
// @ts-ignore
e2e.getScenarioContext().then(({ lastAddedDataSource }) => {
createQueryVariable({
dataSourceName: lastAddedDataSource,
name,
label,
query,
options,
selectedOption,
});
});
assertVariableTable(asserts);
......@@ -565,7 +569,11 @@ e2e.scenario({
addScenarioDashBoard: true,
skipScenario: false,
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.Settings.General.sectionItems('Variables').click();
e2e.pages.Dashboard.Settings.Variables.List.addVariableCTA().click();
......
......@@ -7,7 +7,11 @@ e2e.scenario({
addScenarioDashBoard: true,
skipScenario: false,
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.AddDashboard.ctaButtons('Add Query').click();
......
......@@ -12,6 +12,7 @@ module.exports = async baseConfig => {
if (CWD) {
const projectConfig = {
fixturesFolder: `${CWD}/cypress/fixtures`,
integrationFolder: `${CWD}/cypress/integration`,
screenshotsFolder: `${CWD}/cypress/screenshots`,
videosFolder: `${CWD}/cypress/videos`,
......
const compareSnapshotsPlugin = require('./compareSnapshots');
const extendConfig = require('./extendConfig');
const readProvisions = require('./readProvisions');
const typescriptPreprocessor = require('./typescriptPreprocessor');
module.exports = (on, config) => {
......@@ -10,7 +11,7 @@ module.exports = (on, config) => {
// failed: require('cypress-failed-log/src/failed')(),
// });
on('file:preprocessor', typescriptPreprocessor);
on('task', { compareSnapshotsPlugin });
on('task', { compareSnapshotsPlugin, readProvisions });
on('task', {
log({ message, optional }) {
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) => {
Cypress.Commands.add('logToConsole', (message: string, optional?: any) => {
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 {
interface Chainable {
compareSnapshot(args: CompareSnapshotArgs): void;
logToConsole(message: string, optional?: any): void;
readProvisions(filePaths: string[]): Chainable;
}
}
......@@ -55,6 +55,7 @@
"cypress": "3.7.0",
"execa": "4.0.0",
"ts-loader": "6.2.1",
"typescript": "3.7.2"
"typescript": "3.7.2",
"yaml": "^1.8.3"
}
}
import { e2e } from '../index';
import { Url } from '../support/url';
import { getDashboardUid } from '../support/url';
export const addDashboard = () => {
e2e().logToConsole('Adding dashboard');
......@@ -11,7 +11,9 @@ export const addDashboard = () => {
e2e()
.url()
.then((url: string) => {
e2e.context().set('lastAddedDashboard', dashboardTitle);
e2e.context().set('lastAddedDashboardUid', Url.getDashboardUid(url));
e2e.setScenarioContext({
lastAddedDashboard: dashboardTitle,
lastAddedDashboardUid: getDashboardUid(url),
});
});
};
import { e2e } from '../index';
import { fromBaseUrl, getDataSourceId } from '../support/url';
import { setScenarioContext } from '../support/scenarioContext';
export const addDataSource = (pluginName?: string): string => {
pluginName = pluginName || 'TestData DB';
e2e().logToConsole('Adding data source with pluginName:', pluginName);
export interface AddDataSourceConfig {
checkHealth: boolean;
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.dataSourcePlugins(pluginName)
e2e.pages.AddDataSource.dataSourcePlugins(name)
.scrollIntoView()
.should('be.visible') // prevents flakiness
.click();
const dataSourceName = `e2e-${new Date().getTime()}`;
const dataSourceName = `e2e-${Date.now()}`;
e2e.pages.DataSource.name().clear();
e2e.pages.DataSource.name().type(dataSourceName);
form();
e2e.pages.DataSource.saveAndTest().click();
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.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;
};
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 { fromBaseUrl } from '../support/url';
export const deleteDashboard = (dashBoardUid: string) => {
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
Flows.openDashboard(dashboardName);
......
import { Url } from '../support/url';
import { e2e } from '../index';
import { fromBaseUrl } from '../support/url';
export const deleteDataSource = (dataSourceName: string) => {
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
Pages.DataSources.visit();
......
import { login } from './login';
import { addDataSource } from './addDataSource';
import { deleteDataSource } from './deleteDataSource';
import { addDashboard } from './addDashboard';
import { addDataSource } from './addDataSource';
import { addPanel } from './addPanel';
import { assertSuccessNotification } from './assertSuccessNotification';
import { deleteDashboard } from './deleteDashboard';
import { deleteDataSource } from './deleteDataSource';
import { login } from './login';
import { openDashboard } from './openDashboard';
import { saveNewDashboard } from './saveNewDashboard';
import { saveDashboard } from './saveDashboard';
import { saveNewDashboard } from './saveNewDashboard';
export const Flows = {
login,
addDataSource,
deleteDataSource,
addDashboard,
addDataSource,
addPanel,
assertSuccessNotification,
deleteDashboard,
deleteDataSource,
login,
openDashboard,
saveNewDashboard,
saveDashboard,
saveNewDashboard,
};
......@@ -3,7 +3,7 @@ import { e2e } from '../index';
export const saveNewDashboard = () => {
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().type(dashboardTitle);
e2e.pages.SaveDashboardAsModal.save().click();
......
......@@ -6,7 +6,7 @@
import { e2eScenario, ScenarioArguments } from './support/scenario';
import { Pages } from './pages';
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 SelectorObject<S> = {
......@@ -20,9 +20,10 @@ const e2eObject = {
blobToBase64String: (blob: any) => Cypress.Blob.blobToBase64String(blob),
imgSrcToBlob: (url: string) => Cypress.Blob.imgSrcToBlob(url),
scenario: (args: ScenarioArguments) => e2eScenario(args),
context: scenarioContext,
pages: Pages,
flows: Flows,
getScenarioContext,
setScenarioContext,
};
export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject);
......@@ -3,7 +3,7 @@ import { e2e } from '../index';
export interface ScenarioArguments {
describeName: string;
itName: string;
scenario: () => void;
scenario: Function;
skipScenario?: boolean;
addScenarioDataSource?: boolean;
addScenarioDashBoard?: boolean;
......@@ -19,34 +19,33 @@ export const e2eScenario = ({
}: ScenarioArguments) => {
describe(describeName, () => {
if (skipScenario) {
it.skip(itName, () => {
// @ts-ignore yarn start in root throws error otherwise
expect(false).equals(true);
it.skip(itName, () => scenario());
} else {
beforeEach(() => {
e2e.flows.login('admin', 'admin');
if (addScenarioDataSource) {
e2e.flows.addDataSource();
}
if (addScenarioDashBoard) {
e2e.flows.addDashboard();
}
});
return;
}
beforeEach(() => {
e2e.flows.login('admin', 'admin');
if (addScenarioDataSource) {
e2e.flows.addDataSource('TestData DB');
}
if (addScenarioDashBoard) {
e2e.flows.addDashboard();
}
});
afterEach(() => {
// @todo remove `@ts-ignore` when possible
// @ts-ignore
e2e.getScenarioContext().then(({ lastAddedDashboardUid, lastAddedDataSource }) => {
if (lastAddedDataSource) {
e2e.flows.deleteDataSource(lastAddedDataSource);
}
afterEach(() => {
if (e2e.context().get('lastAddedDataSource')) {
e2e.flows.deleteDataSource(e2e.context().get('lastAddedDataSource'));
}
if (e2e.context().get('lastAddedDashboardUid')) {
e2e.flows.deleteDashboard(e2e.context().get('lastAddedDashboardUid'));
}
});
if (lastAddedDashboardUid) {
e2e.flows.deleteDashboard(lastAddedDashboardUid);
}
});
});
it(itName, () => {
scenario();
});
it(itName, () => scenario());
}
});
};
import { e2e } from '../index';
export interface ScenarioContext {
lastAddedDataSource: string;
lastAddedDashboard: string;
lastAddedDashboardUid: string;
lastAddedDataSource: string;
lastAddedDataSourceId: string;
[key: string]: any;
}
const scenarioContexts: ScenarioContext = {
lastAddedDataSource: '',
const scenarioContext: ScenarioContext = {
lastAddedDashboard: '',
lastAddedDashboardUid: '',
lastAddedDataSource: '',
lastAddedDataSourceId: '',
};
export interface ScenarioContextApi {
get: <T>(name: string | keyof ScenarioContext) => T;
set: <T>(name: string | keyof ScenarioContext, value: T) => void;
}
export const scenarioContext = (): ScenarioContextApi => {
const get = <T>(name: string | keyof ScenarioContext): T => scenarioContexts[name] as T;
const set = <T>(name: string | keyof ScenarioContext, value: T): void => {
scenarioContexts[name] = value;
};
// @todo this actually returns type `Cypress.Chainable`
export const getScenarioContext = (): any =>
e2e()
.wrap({
getScenarioContext: () => ({ ...scenarioContext } as ScenarioContext),
})
.invoke('getScenarioContext');
return {
get,
set,
};
};
// @todo this actually returns type `Cypress.Chainable`
export const setScenarioContext = (newContext: Partial<ScenarioContext>): any =>
e2e()
.wrap({
setScenarioContext: () => {
Object.entries(newContext).forEach(([key, value]) => {
scenarioContext[key] = value;
});
},
})
.invoke('setScenarioContext');
import { Selector } from './selector';
import { Url } from './url';
import { fromBaseUrl } from './url';
import { e2e } from '../index';
import { SelectorFunction, SelectorObject } from '../noTypeCheck';
......@@ -19,11 +19,11 @@ export const pageFactory = <S extends Selectors>({ url, selectors }: PageFactory
let parsedUrl = '';
if (typeof url === 'string') {
parsedUrl = Url.fromBaseUrl(url);
parsedUrl = fromBaseUrl(url);
}
if (typeof url === 'function' && args) {
parsedUrl = Url.fromBaseUrl(url(args));
parsedUrl = fromBaseUrl(url(args));
}
e2e().logToConsole('Visiting', parsedUrl);
......
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}`);
}
export const fromBaseUrl = (url = ''): string => {
const strippedUrl = url.replace('^/', '');
return `${getBaseUrl()}${strippedUrl}`;
};
export const getDashboardUid = (url: string): string => {
const matches = url.match(/\/d\/(.*)\//);
if (!matches) {
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];
},
}
};
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