Chore: Move to Cypress 12 and decouple cypress from @grafana/e2e (#74084)

* update drone to use cypress 12 image

* upgrade cypress to 12 in core

* cypress config actually valid

* update @grafana/e2e imports and add lint rule

* ignore grafana-e2e from betterer now it's deprecated

* fix remaining type errors

* fix failing tests

* remove unnecessary tsconfig

* remove unnecessary comment

* update enterprise suite commands to work

* add cypress config to CODEOWNERS

* export setTimeRange in utils

* remove @grafana/e2e from core deps

* try running the command through yarn

* move CMD to scripts

* Update cloud-data-sources e2e image

* Update paths

---------

Co-authored-by: Andreas Christou <andreas.christou@grafana.com>
This commit is contained in:
Ashley Harrison
2023-09-08 16:51:59 +01:00
committed by GitHub
co-authored by Andreas Christou
parent e7a2c95586
commit 0f2f25c5d9
116 changed files with 11747 additions and 223 deletions
+303
View File
@@ -0,0 +1,303 @@
import { v4 as uuidv4 } from 'uuid';
import { e2e } from '../index';
import { getDashboardUid } from '../support/url';
import { DeleteDashboardConfig } from './deleteDashboard';
import { selectOption } from './selectOption';
import { setDashboardTimeRange, TimeRangeConfig } from './setDashboardTimeRange';
export interface AddAnnotationConfig {
dataSource: string;
dataSourceForm?: () => void;
name: string;
}
export interface AddDashboardConfig {
annotations: AddAnnotationConfig[];
timeRange: TimeRangeConfig;
title: string;
variables: PartialAddVariableConfig[];
}
interface AddVariableDefault {
hide: string;
type: string;
}
interface AddVariableOptional {
constantValue?: string;
dataSource?: string;
label?: string;
query?: string;
regex?: string;
variableQueryForm?: (config: AddVariableConfig) => void;
}
interface AddVariableRequired {
name: string;
}
export type PartialAddVariableConfig = Partial<AddVariableDefault> & AddVariableOptional & AddVariableRequired;
export type AddVariableConfig = AddVariableDefault & AddVariableOptional & AddVariableRequired;
/**
* This flow is used to add a dashboard with whatever configuration specified.
* @param config Configuration object. Currently supports configuring dashboard time range, annotations, and variables (support dependant on type).
* @see{@link AddDashboardConfig}
*
* @example
* ```
* // Configuring a simple dashboard
* addDashboard({
* timeRange: {
* from: '2022-10-03 00:00:00',
* to: '2022-10-03 23:59:59',
* zone: 'Coordinated Universal Time',
* },
* title: 'Test Dashboard',
* })
* ```
*
* @example
* ```
* // Configuring a dashboard with annotations
* addDashboard({
* title: 'Test Dashboard',
* annotations: [
* {
* // This should match the datasource name
* dataSource: 'azure-monitor',
* name: 'Test Annotation',
* dataSourceForm: () => {
* // Insert steps to create annotation using datasource form
* }
* }
* ]
* })
* ```
*
* @see{@link AddAnnotationConfig}
*
* @example
* ```
* // Configuring a dashboard with variables
* addDashboard({
* title: 'Test Dashboard',
* variables: [
* {
* name: 'test-query-variable',
* label: 'Testing Query',
* hide: '',
* type: e2e.flows.VARIABLE_TYPE_QUERY,
* dataSource: 'azure-monitor',
* variableQueryForm: () => {
* // Insert steps to create variable using datasource form
* },
* },
* {
* name: 'test-constant-variable',
* label: 'Testing Constant',
* type: e2e.flows.VARIABLE_TYPE_CONSTANT,
* constantValue: 'constant',
* }
* ]
* })
* ```
*
* @see{@link AddVariableConfig}
*
* @see{@link https://github.com/grafana/grafana/blob/main/e2e/cloud-plugins-suite/azure-monitor.spec.ts Azure Monitor Tests for full examples}
*/
export const addDashboard = (config?: Partial<AddDashboardConfig>) => {
const fullConfig: AddDashboardConfig = {
annotations: [],
title: `e2e-${uuidv4()}`,
variables: [],
...config,
timeRange: {
from: '2020-01-01 00:00:00',
to: '2020-01-01 06:00:00',
zone: 'Coordinated Universal Time',
...config?.timeRange,
},
};
const { annotations, timeRange, title, variables } = fullConfig;
e2e().logToConsole('Adding dashboard with title:', title);
e2e.pages.AddDashboard.visit();
if (annotations.length > 0 || variables.length > 0) {
e2e.components.PageToolbar.item('Dashboard settings').click();
addAnnotations(annotations);
fullConfig.variables = addVariables(variables);
e2e.components.BackButton.backArrow().should('be.visible').click({ force: true });
}
setDashboardTimeRange(timeRange);
e2e.components.PageToolbar.item('Save dashboard').click();
e2e.pages.SaveDashboardAsModal.newName().clear().type(title, { force: true });
e2e.pages.SaveDashboardAsModal.save().click();
e2e.flows.assertSuccessNotification();
e2e.pages.AddDashboard.itemButton('Create new panel button').should('be.visible');
e2e().logToConsole('Added dashboard with title:', title);
return e2e()
.url()
.should('contain', '/d/')
.then((url: string) => {
const uid = 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,
},
{ log: false }
);
});
};
const addAnnotation = (config: AddAnnotationConfig, isFirst: boolean) => {
if (isFirst) {
if (e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2) {
e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTAV2().click();
} else {
e2e.pages.Dashboard.Settings.Annotations.List.addAnnotationCTA().click();
}
} else {
cy.contains('New query').click();
}
const { dataSource, dataSourceForm, name } = config;
selectOption({
container: e2e.components.DataSourcePicker.container(),
optionText: dataSource,
});
e2e.pages.Dashboard.Settings.Annotations.Settings.name().clear().type(name);
if (dataSourceForm) {
dataSourceForm();
}
};
const addAnnotations = (configs: AddAnnotationConfig[]) => {
if (configs.length > 0) {
e2e.pages.Dashboard.Settings.General.sectionItems('Annotations').click();
}
return configs.forEach((config, i) => addAnnotation(config, i === 0));
};
export const VARIABLE_HIDE_LABEL = 'Label';
export const VARIABLE_HIDE_NOTHING = '';
export const VARIABLE_HIDE_VARIABLE = 'Variable';
export const VARIABLE_TYPE_AD_HOC_FILTERS = 'Ad hoc filters';
export const VARIABLE_TYPE_CONSTANT = 'Constant';
export const VARIABLE_TYPE_DATASOURCE = 'Datasource';
export const VARIABLE_TYPE_QUERY = 'Query';
const addVariable = (config: PartialAddVariableConfig, isFirst: boolean): AddVariableConfig => {
const fullConfig = {
hide: VARIABLE_HIDE_NOTHING,
type: VARIABLE_TYPE_QUERY,
...config,
};
if (isFirst) {
if (e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2) {
e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2().click();
} else {
e2e.pages.Dashboard.Settings.Variables.List.addVariableCTA().click();
}
} else {
e2e.pages.Dashboard.Settings.Variables.List.newButton().click();
}
const { constantValue, dataSource, label, name, query, regex, type, variableQueryForm } = fullConfig;
// This field is key to many reactive changes
if (type !== VARIABLE_TYPE_QUERY) {
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2()
.should('be.visible')
.within(() => {
e2e.components.Select.singleValue().should('have.text', 'Query').parent().click();
});
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalTypeSelectV2().find('input').type(`${type}{enter}`);
}
if (label) {
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type(label);
}
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type(name);
if (
dataSource &&
(type === VARIABLE_TYPE_AD_HOC_FILTERS || type === VARIABLE_TYPE_DATASOURCE || type === VARIABLE_TYPE_QUERY)
) {
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsDataSourceSelect()
.should('be.visible')
.within(() => {
e2e.components.DataSourcePicker.inputV2().type(`${dataSource}{enter}`);
});
}
if (constantValue && type === VARIABLE_TYPE_CONSTANT) {
e2e.pages.Dashboard.Settings.Variables.Edit.ConstantVariable.constantOptionsQueryInputV2().type(constantValue);
}
if (type === VARIABLE_TYPE_QUERY) {
if (query) {
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput().type(query);
}
if (regex) {
e2e.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2().type(regex);
}
if (variableQueryForm) {
variableQueryForm(fullConfig);
}
}
// Avoid flakiness
e2e().focused().blur();
e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption()
.should('exist')
.within((previewOfValues) => {
if (type === VARIABLE_TYPE_CONSTANT) {
expect(previewOfValues.text()).equals(constantValue);
}
});
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
e2e.pages.Dashboard.Settings.Variables.Edit.General.applyButton().click();
return fullConfig;
};
const addVariables = (configs: PartialAddVariableConfig[]): AddVariableConfig[] => {
if (configs.length > 0) {
e2e.components.Tab.title('Variables').click();
}
return configs.map((config, i) => addVariable(config, i === 0));
};
+116
View File
@@ -0,0 +1,116 @@
import { v4 as uuidv4 } from 'uuid';
import { e2e } from '../index';
import { DeleteDataSourceConfig } from './deleteDataSource';
export interface AddDataSourceConfig {
basicAuth: boolean;
basicAuthPassword: string;
basicAuthUser: string;
expectedAlertMessage: string | RegExp;
form: () => void;
name: string;
skipTlsVerify: boolean;
type: string;
timeout?: number;
awaitHealth?: boolean;
}
// @todo this actually returns type `Cypress.Chainable<AddDaaSourceConfig>`
export const addDataSource = (config?: Partial<AddDataSourceConfig>) => {
const fullConfig: AddDataSourceConfig = {
basicAuth: false,
basicAuthPassword: '',
basicAuthUser: '',
expectedAlertMessage: 'Data source is working',
form: () => {},
name: `e2e-${uuidv4()}`,
skipTlsVerify: false,
type: 'TestData',
...config,
};
const {
basicAuth,
basicAuthPassword,
basicAuthUser,
expectedAlertMessage,
form,
name,
skipTlsVerify,
type,
timeout,
awaitHealth,
} = fullConfig;
if (awaitHealth) {
e2e()
.intercept(/health/)
.as('health');
}
e2e().logToConsole('Adding data source with name:', name);
e2e.pages.AddDataSource.visit();
e2e.pages.AddDataSource.dataSourcePluginsV2(type)
.scrollIntoView()
.should('be.visible') // prevents flakiness
.click();
e2e.pages.DataSource.name().clear();
e2e.pages.DataSource.name().type(name);
if (basicAuth) {
e2e().contains('label', 'Basic auth').scrollIntoView().click();
e2e()
.contains('.gf-form-group', 'Basic Auth Details')
.should('be.visible')
.scrollIntoView()
.within(() => {
if (basicAuthUser) {
e2e().get('[placeholder=user]').type(basicAuthUser);
}
if (basicAuthPassword) {
e2e().get('[placeholder=Password]').type(basicAuthPassword);
}
});
}
if (skipTlsVerify) {
e2e().contains('label', 'Skip TLS Verify').scrollIntoView().click();
}
form();
e2e.pages.DataSource.saveAndTest().click();
if (awaitHealth) {
e2e().wait('@health', { timeout: timeout ?? e2e.config().defaultCommandTimeout });
}
// use the timeout passed in if it exists, otherwise, continue to use the default
e2e.pages.DataSource.alert()
.should('exist')
.contains(expectedAlertMessage, {
timeout: timeout ?? e2e.config().defaultCommandTimeout,
});
e2e().logToConsole('Added data source with name:', name);
return e2e()
.url()
.then(() => {
e2e.getScenarioContext().then(({ addedDataSources }: any) => {
e2e.setScenarioContext({
addedDataSources: [...addedDataSources, { name } as DeleteDataSourceConfig],
});
});
// @todo remove `wrap` when possible
return e2e().wrap(
{
config: fullConfig,
},
{ log: false }
);
});
};
+15
View File
@@ -0,0 +1,15 @@
import { v4 as uuidv4 } from 'uuid';
import { getScenarioContext } from '../support/scenarioContext';
import { configurePanel, PartialAddPanelConfig } from './configurePanel';
export const addPanel = (config?: Partial<PartialAddPanelConfig>) =>
getScenarioContext().then(({ lastAddedDataSource }: any) =>
configurePanel({
dataSourceName: lastAddedDataSource,
panelTitle: `e2e-${uuidv4()}`,
...config,
isEdit: false,
})
);
@@ -0,0 +1,9 @@
import { e2e } from '../index';
export const assertSuccessNotification = () => {
if (e2e.components.Alert.alertV2) {
e2e.components.Alert.alertV2('success').should('exist');
} else {
e2e.components.Alert.alert('success').should('exist');
}
};
+192
View File
@@ -0,0 +1,192 @@
import { e2e } from '..';
import { getScenarioContext } from '../support/scenarioContext';
import { setDashboardTimeRange } from './setDashboardTimeRange';
import { TimeRangeConfig } from './setTimeRange';
interface AddPanelOverrides {
dataSourceName: string;
queriesForm: (config: AddPanelConfig) => void;
panelTitle: string;
}
interface EditPanelOverrides {
queriesForm?: (config: EditPanelConfig) => void;
panelTitle: string;
}
interface ConfigurePanelDefault {
chartData: {
method: string;
route: string | RegExp;
};
dashboardUid: string;
matchScreenshot: boolean;
saveDashboard: boolean;
screenshotName: string;
visitDashboardAtStart: boolean; // @todo remove when possible
}
interface ConfigurePanelOptional {
dataSourceName?: string;
queriesForm?: (config: ConfigurePanelConfig) => void;
panelTitle?: string;
timeRange?: TimeRangeConfig;
visualizationName?: string;
timeout?: number;
}
interface ConfigurePanelRequired {
isEdit: boolean;
}
export type PartialConfigurePanelConfig = Partial<ConfigurePanelDefault> &
ConfigurePanelOptional &
ConfigurePanelRequired;
export type ConfigurePanelConfig = ConfigurePanelDefault & ConfigurePanelOptional & ConfigurePanelRequired;
export type PartialAddPanelConfig = PartialConfigurePanelConfig & AddPanelOverrides;
export type AddPanelConfig = ConfigurePanelConfig & AddPanelOverrides;
export type PartialEditPanelConfig = PartialConfigurePanelConfig & EditPanelOverrides;
export type EditPanelConfig = ConfigurePanelConfig & EditPanelOverrides;
// @todo this actually returns type `Cypress.Chainable<AddPanelConfig | EditPanelConfig | ConfigurePanelConfig>`
export const configurePanel = (config: PartialAddPanelConfig | PartialEditPanelConfig | PartialConfigurePanelConfig) =>
getScenarioContext().then(({ lastAddedDashboardUid }: any) => {
const fullConfig: AddPanelConfig | EditPanelConfig | ConfigurePanelConfig = {
chartData: {
method: 'POST',
route: '/api/ds/query',
},
dashboardUid: lastAddedDashboardUid,
matchScreenshot: false,
saveDashboard: true,
screenshotName: 'panel-visualization',
visitDashboardAtStart: true,
...config,
};
const {
chartData,
dashboardUid,
dataSourceName,
isEdit,
matchScreenshot,
panelTitle,
queriesForm,
screenshotName,
timeRange,
visitDashboardAtStart,
visualizationName,
timeout,
} = fullConfig;
if (visitDashboardAtStart) {
e2e.flows.openDashboard({ uid: dashboardUid });
}
if (isEdit) {
e2e.components.Panels.Panel.title(panelTitle).click();
e2e.components.Panels.Panel.headerItems('Edit').click();
} else {
try {
e2e.components.PageToolbar.itemButton('Add button').should('be.visible');
e2e.components.PageToolbar.itemButton('Add button').click();
} catch (e) {
// Depending on the screen size, the "Add" button might be hidden
e2e.components.PageToolbar.item('Show more items').click();
e2e.components.PageToolbar.item('Add button').last().click();
}
e2e.pages.AddDashboard.itemButton('Add new visualization menu item').should('be.visible');
e2e.pages.AddDashboard.itemButton('Add new visualization menu item').click();
}
if (timeRange) {
setDashboardTimeRange(timeRange);
}
// @todo alias '/**/*.js*' as '@pluginModule' when possible: https://github.com/cypress-io/cypress/issues/1296
e2e().intercept(chartData.method, chartData.route).as('chartData');
if (dataSourceName) {
e2e.components.DataSourcePicker.container().click().type(`${dataSourceName}{downArrow}{enter}`);
}
// @todo instead wait for '@pluginModule' if not already loaded
e2e().wait(2000);
// `panelTitle` is needed to edit the panel, and unlikely to have its value changed at that point
const changeTitle = panelTitle && !isEdit;
if (changeTitle || visualizationName) {
if (changeTitle && panelTitle) {
e2e.components.PanelEditor.OptionsPane.fieldLabel('Panel options Title').type(`{selectall}${panelTitle}`);
}
if (visualizationName) {
e2e.components.PluginVisualization.item(visualizationName).scrollIntoView().click();
// @todo wait for '@pluginModule' if not a core visualization and not already loaded
e2e().wait(2000);
}
} else {
// Consistently closed
closeOptions();
}
if (queriesForm) {
queriesForm(fullConfig);
// Wait for a possible complex visualization to render (or something related, as this isn't necessary on the dashboard page)
// Can't assert that its HTML changed because a new query could produce the same results
e2e().wait(1000);
}
// @todo enable when plugins have this implemented
//e2e.components.QueryEditorRow.actionButton('Disable/enable query').click();
//e2e().wait('@chartData');
//e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content').contains('No data');
//e2e.components.QueryEditorRow.actionButton('Disable/enable query').click();
//e2e().wait('@chartData');
// Avoid annotations flakiness
e2e.components.RefreshPicker.runButtonV2().first().click({ force: true });
// Wait for RxJS
e2e().wait(timeout ?? e2e.config().defaultCommandTimeout);
if (matchScreenshot) {
let visualization;
visualization = e2e.components.Panels.Panel.containerByTitle(panelTitle).find('.panel-content');
visualization.scrollIntoView().screenshot(screenshotName);
e2e().compareScreenshots(screenshotName);
}
// @todo remove `wrap` when possible
return e2e().wrap({ config: fullConfig }, { log: false });
});
// @todo this actually returns type `Cypress.Chainable`
const closeOptions = () => e2e.components.PanelEditor.toggleVizOptions().click();
export const VISUALIZATION_ALERT_LIST = 'Alert list';
export const VISUALIZATION_BAR_GAUGE = 'Bar gauge';
export const VISUALIZATION_CLOCK = 'Clock';
export const VISUALIZATION_DASHBOARD_LIST = 'Dashboard list';
export const VISUALIZATION_GAUGE = 'Gauge';
export const VISUALIZATION_GRAPH = 'Graph';
export const VISUALIZATION_HEAT_MAP = 'Heatmap';
export const VISUALIZATION_LOGS = 'Logs';
export const VISUALIZATION_NEWS = 'News';
export const VISUALIZATION_PIE_CHART = 'Pie Chart';
export const VISUALIZATION_PLUGIN_LIST = 'Plugin list';
export const VISUALIZATION_POLYSTAT = 'Polystat';
export const VISUALIZATION_STAT = 'Stat';
export const VISUALIZATION_TABLE = 'Table';
export const VISUALIZATION_TEXT = 'Text';
export const VISUALIZATION_WORLD_MAP = 'Worldmap Panel';
+51
View File
@@ -0,0 +1,51 @@
import { e2e } from '../index';
import { fromBaseUrl } from '../support/url';
export interface DeleteDashboardConfig {
quick?: boolean;
title: string;
uid: string;
}
export const deleteDashboard = ({ quick = false, title, uid }: DeleteDashboardConfig) => {
e2e().logToConsole('Deleting dashboard with uid:', uid);
if (quick) {
quickDelete(uid);
} else {
uiDelete(uid, title);
}
e2e().logToConsole('Deleted dashboard with uid:', uid);
e2e.getScenarioContext().then(({ addedDashboards }: any) => {
e2e.setScenarioContext({
addedDashboards: addedDashboards.filter((dashboard: DeleteDashboardConfig) => {
return dashboard.title !== title && dashboard.uid !== uid;
}),
});
});
};
const quickDelete = (uid: string) => {
e2e().request('DELETE', fromBaseUrl(`/api/dashboards/uid/${uid}`));
};
const uiDelete = (uid: string, title: string) => {
e2e.pages.Dashboard.visit(uid);
e2e.components.PageToolbar.item('Dashboard settings').click();
e2e.pages.Dashboard.Settings.General.deleteDashBoard().click();
e2e.pages.ConfirmModal.delete().click();
e2e.flows.assertSuccessNotification();
e2e.pages.Dashboards.visit();
// @todo replace `e2e.pages.Dashboards.dashboards` with this when argument is empty
if (e2e.components.Search.dashboardItems) {
e2e.components.Search.dashboardItems().each((item) => e2e().wrap(item).should('not.contain', title));
} else {
e2e()
.get('[aria-label^="Dashboard search item "]')
.each((item) => e2e().wrap(item).should('not.contain', title));
}
};
+46
View File
@@ -0,0 +1,46 @@
import { e2e } from '../index';
import { fromBaseUrl } from '../support/url';
export interface DeleteDataSourceConfig {
id: string;
name: string;
quick?: boolean;
}
export const deleteDataSource = ({ id, name, quick = false }: DeleteDataSourceConfig) => {
e2e().logToConsole('Deleting data source with name:', name);
if (quick) {
quickDelete(name);
} else {
uiDelete(name);
}
e2e().logToConsole('Deleted data source with name:', name);
e2e.getScenarioContext().then(({ addedDataSources }: any) => {
e2e.setScenarioContext({
addedDataSources: addedDataSources.filter((dataSource: DeleteDataSourceConfig) => {
return dataSource.id !== id && dataSource.name !== name;
}),
});
});
};
const quickDelete = (name: string) => {
e2e().request('DELETE', fromBaseUrl(`/api/datasources/name/${name}`));
};
const uiDelete = (name: string) => {
e2e.pages.DataSources.visit();
e2e.pages.DataSources.dataSources(name).click();
e2e.pages.DataSource.delete().click();
e2e.pages.ConfirmModal.delete().click();
e2e.pages.DataSources.visit();
// @todo replace `e2e.pages.DataSources.dataSources` with this when argument is empty
e2e()
.get('[aria-label^="Data source list item "]')
.each((item) => e2e().wrap(item).should('not.contain', name));
};
+7
View File
@@ -0,0 +1,7 @@
import { configurePanel, PartialEditPanelConfig } from './configurePanel';
export const editPanel = (config: Partial<PartialEditPanelConfig>) =>
configurePanel({
...config,
isEdit: true,
});
+70
View File
@@ -0,0 +1,70 @@
import { e2e } from '../index';
import { fromBaseUrl, getDashboardUid } from '../support/url';
import { DeleteDashboardConfig } from '.';
type Panel = {
title: string;
[key: string]: unknown;
};
export type Dashboard = { title: string; panels: Panel[]; uid: string; [key: string]: unknown };
/**
* Smoke test a particular dashboard by quickly importing a json file and validate that all the panels finish loading
* @param dashboardToImport a sample dashboard
* @param queryTimeout a number of ms to wait for the imported dashboard to finish loading
* @param skipPanelValidation skip panel validation
*/
export const importDashboard = (dashboardToImport: Dashboard, queryTimeout?: number, skipPanelValidation?: boolean) => {
e2e().visit(fromBaseUrl('/dashboard/import'));
// Note: normally we'd use 'click' and then 'type' here, but the json object is so big that using 'val' is much faster
e2e.components.DashboardImportPage.textarea().should('be.visible');
e2e.components.DashboardImportPage.textarea().click();
e2e.components.DashboardImportPage.textarea().invoke('val', JSON.stringify(dashboardToImport));
e2e.components.DashboardImportPage.submit().should('be.visible').click();
e2e.components.ImportDashboardForm.name().should('be.visible').click().clear().type(dashboardToImport.title);
e2e.components.ImportDashboardForm.submit().should('be.visible').click();
// wait for dashboard to load
e2e().wait(queryTimeout || 6000);
// save the newly imported dashboard to context so it'll get properly deleted later
e2e()
.url()
.should('contain', '/d/')
.then((url: string) => {
const uid = getDashboardUid(url);
e2e.getScenarioContext().then(({ addedDashboards }: { addedDashboards: DeleteDashboardConfig[] }) => {
e2e.setScenarioContext({
addedDashboards: [...addedDashboards, { title: dashboardToImport.title, uid }],
});
});
expect(dashboardToImport.uid).to.equal(uid);
});
if (!skipPanelValidation) {
dashboardToImport.panels.forEach((panel) => {
// Look at the json data
e2e.components.Panels.Panel.menu(panel.title).click({ force: true }); // force click because menu is hidden and show on hover
e2e.components.Panels.Panel.menuItems('Inspect').should('be.visible').click();
e2e.components.Tab.title('JSON').should('be.visible').click();
e2e.components.PanelInspector.Json.content().should('be.visible').contains('Panel JSON').click({ force: true });
e2e.components.Select.option().should('be.visible').contains('Panel data').click();
// ensures that panel has loaded without knowingly hitting an error
// note: this does not prove that data came back as we expected it,
// it could get `state: Done` for no data for example
// but it ensures we didn't hit a 401 or 500 or something like that
e2e.components.CodeEditor.container()
.should('be.visible')
.contains(/"state": "(Done|Streaming)"/);
// need to close panel
e2e.components.Drawer.General.close().click();
});
}
};
+21
View File
@@ -0,0 +1,21 @@
import { e2e } from '../index';
import { importDashboard, Dashboard } from './importDashboard';
/**
* Smoke test several dashboard json files from a test directory
* and validate that all the panels in each import finish loading their queries
* @param dirPath the relative path to a directory which contains json files representing dashboards,
* for example if your dashboards live in `cypress/testDashboards` you can pass `/testDashboards`
* @param queryTimeout a number of ms to wait for the imported dashboard to finish loading
* @param skipPanelValidation skips panel validation
*/
export const importDashboards = async (dirPath: string, queryTimeout?: number, skipPanelValidation?: boolean) => {
e2e()
.getJSONFilesFromDir(dirPath)
.then((jsonFiles: Dashboard[]) => {
jsonFiles.forEach((file) => {
importDashboard(file, queryTimeout || 6000, skipPanelValidation);
});
});
};
+36
View File
@@ -0,0 +1,36 @@
export * from './addDashboard';
export * from './addDataSource';
export * from './addPanel';
export * from './assertSuccessNotification';
export * from './deleteDashboard';
export * from './deleteDataSource';
export * from './editPanel';
export * from './login';
export * from './openDashboard';
export * from './openPanelMenuItem';
export * from './revertAllChanges';
export * from './saveDashboard';
export * from './selectOption';
export * from './setTimeRange';
export * from './importDashboard';
export * from './importDashboards';
export * from './userPreferences';
export {
VISUALIZATION_ALERT_LIST,
VISUALIZATION_BAR_GAUGE,
VISUALIZATION_CLOCK,
VISUALIZATION_DASHBOARD_LIST,
VISUALIZATION_GAUGE,
VISUALIZATION_GRAPH,
VISUALIZATION_HEAT_MAP,
VISUALIZATION_LOGS,
VISUALIZATION_NEWS,
VISUALIZATION_PIE_CHART,
VISUALIZATION_PLUGIN_LIST,
VISUALIZATION_POLYSTAT,
VISUALIZATION_STAT,
VISUALIZATION_TABLE,
VISUALIZATION_TEXT,
VISUALIZATION_WORLD_MAP,
} from './configurePanel';
+42
View File
@@ -0,0 +1,42 @@
import { e2e } from '../index';
import { fromBaseUrl } from '../support/url';
const DEFAULT_USERNAME = 'admin';
const DEFAULT_PASSWORD = 'admin';
const loginApi = (username: string, password: string) => {
cy.request({
method: 'POST',
url: fromBaseUrl('/login'),
body: {
user: username,
password,
},
});
};
const loginUi = (username: string, password: string) => {
e2e().logToConsole('Logging in with username:', username);
e2e.pages.Login.visit();
e2e.pages.Login.username()
.should('be.visible') // prevents flakiness
.type(username);
e2e.pages.Login.password().type(password);
e2e.pages.Login.submit().click();
// Local tests will have insecure credentials
if (password === DEFAULT_PASSWORD) {
e2e.pages.Login.skip().should('be.visible').click();
}
e2e().get('.login-page').should('not.exist');
};
export const login = (username = DEFAULT_USERNAME, password = DEFAULT_PASSWORD, loginViaApi = true) => {
if (loginViaApi) {
loginApi(username, password);
} else {
loginUi(username, password);
}
e2e().logToConsole('Logged in with username:', username);
};
+36
View File
@@ -0,0 +1,36 @@
import { e2e } from '../index';
import { getScenarioContext } from '../support/scenarioContext';
import { setDashboardTimeRange, TimeRangeConfig } from './setDashboardTimeRange';
interface OpenDashboardDefault {
uid: string;
}
interface OpenDashboardOptional {
timeRange?: TimeRangeConfig;
queryParams?: object;
}
export type PartialOpenDashboardConfig = Partial<OpenDashboardDefault> & OpenDashboardOptional;
export type OpenDashboardConfig = OpenDashboardDefault & OpenDashboardOptional;
// @todo this actually returns type `Cypress.Chainable<OpenDashboardConfig>`
export const openDashboard = (config?: PartialOpenDashboardConfig) =>
getScenarioContext().then(({ lastAddedDashboardUid }: any) => {
const fullConfig: OpenDashboardConfig = {
uid: lastAddedDashboardUid,
...config,
};
const { timeRange, uid, queryParams } = fullConfig;
e2e.pages.Dashboard.visit(uid, queryParams);
if (timeRange) {
setDashboardTimeRange(timeRange);
}
// @todo remove `wrap` when possible
return e2e().wrap({ config: fullConfig }, { log: false });
});
+57
View File
@@ -0,0 +1,57 @@
import { e2e } from '../index';
export enum PanelMenuItems {
Edit = 'Edit',
Inspect = 'Inspect',
More = 'More...',
Extensions = 'Extensions',
}
export const openPanelMenuItem = (menu: PanelMenuItems, panelTitle = 'Panel Title') => {
// we changed the way we open the panel menu in react panels with the new panel header
detectPanelType(panelTitle, (isAngularPanel) => {
if (isAngularPanel) {
e2e.components.Panels.Panel.title(panelTitle).should('be.visible').click();
e2e.components.Panels.Panel.headerItems(menu).should('be.visible').click();
} else {
e2e.components.Panels.Panel.menu(panelTitle).click({ force: true }); // force click because menu is hidden and show on hover
e2e.components.Panels.Panel.menuItems(menu).should('be.visible').click();
}
});
};
export const openPanelMenuExtension = (extensionTitle: string, panelTitle = 'Panel Title') => {
const menuItem = PanelMenuItems.Extensions;
// we changed the way we open the panel menu in react panels with the new panel header
detectPanelType(panelTitle, (isAngularPanel) => {
if (isAngularPanel) {
e2e.components.Panels.Panel.title(panelTitle).should('be.visible').click();
e2e.components.Panels.Panel.headerItems(menuItem)
.should('be.visible')
.parent()
.parent()
.invoke('addClass', 'open');
e2e.components.Panels.Panel.headerItems(extensionTitle).should('be.visible').click();
} else {
e2e.components.Panels.Panel.menu(panelTitle).click({ force: true }); // force click because menu is hidden and show on hover
e2e.components.Panels.Panel.menuItems(menuItem).trigger('mouseover', { force: true });
e2e.components.Panels.Panel.menuItems(extensionTitle).click({ force: true });
}
});
};
function detectPanelType(panelTitle: string, detected: (isAngularPanel: boolean) => void) {
e2e.components.Panels.Panel.title(panelTitle).then((el) => {
const isAngularPanel = el.find('plugin-component.ng-scope').length > 0;
if (isAngularPanel) {
Cypress.log({
name: 'detectPanelType',
displayName: 'detector',
message: 'Angular panel detected, will use legacy selectors.',
});
}
detected(isAngularPanel);
});
}
+12
View File
@@ -0,0 +1,12 @@
import { e2e } from '../index';
export const revertAllChanges = () => {
e2e.getScenarioContext().then(({ addedDashboards, addedDataSources, hasChangedUserPreferences }) => {
addedDashboards.forEach((dashboard: any) => e2e.flows.deleteDashboard({ ...dashboard, quick: true }));
addedDataSources.forEach((dataSource: any) => e2e.flows.deleteDataSource({ ...dataSource, quick: true }));
if (hasChangedUserPreferences) {
e2e.flows.setDefaultUserPreferences();
}
});
};
+9
View File
@@ -0,0 +1,9 @@
import { e2e } from '../index';
export const saveDashboard = () => {
e2e.components.PageToolbar.item('Save dashboard').click();
e2e.pages.SaveDashboardModal.save().click();
e2e.flows.assertSuccessNotification();
};
+43
View File
@@ -0,0 +1,43 @@
import { e2e } from '../index';
export interface SelectOptionConfig {
clickToOpen?: boolean;
container: any;
forceClickOption?: boolean;
optionText: string | RegExp;
}
// @todo this actually returns type `Cypress.Chainable`
export const selectOption = (config: SelectOptionConfig): any => {
const fullConfig: SelectOptionConfig = {
clickToOpen: true,
forceClickOption: false,
...config,
};
const { clickToOpen, container, forceClickOption, optionText } = fullConfig;
container.within(() => {
if (clickToOpen) {
e2e()
.get('[class$="-input-suffix"]', { timeout: 1000 })
.then((element) => {
expect(Cypress.dom.isAttached(element)).to.eq(true);
e2e().get('[class$="-input-suffix"]', { timeout: 1000 }).click({ force: true });
});
}
});
return e2e.components.Select.option()
.filter((_, { textContent }) => {
if (textContent === null) {
return false;
} else if (typeof optionText === 'string') {
return textContent.includes(optionText);
} else {
return optionText.test(textContent);
}
})
.scrollIntoView()
.click({ force: forceClickOption });
};
+5
View File
@@ -0,0 +1,5 @@
import { setTimeRange, TimeRangeConfig } from './setTimeRange';
export type { TimeRangeConfig };
export const setDashboardTimeRange = (config: TimeRangeConfig) => setTimeRange(config);
+40
View File
@@ -0,0 +1,40 @@
import { e2e } from '../index';
import { selectOption } from './selectOption';
export interface TimeRangeConfig {
from: string;
to: string;
zone?: string;
}
export const setTimeRange = ({ from, to, zone }: TimeRangeConfig) => {
e2e.components.TimePicker.openButton().click();
if (zone) {
e2e().contains('button', 'Change time settings').click();
e2e().log('setting time zone to ' + zone);
if (e2e.components.TimeZonePicker.containerV2) {
selectOption({
clickToOpen: true,
container: e2e.components.TimeZonePicker.containerV2(),
optionText: zone,
});
} else {
selectOption({
clickToOpen: true,
container: e2e.components.TimeZonePicker.container(),
optionText: zone,
});
}
}
// For smaller screens
e2e.components.TimePicker.absoluteTimeRangeTitle().click();
e2e.components.TimePicker.fromField().clear().type(from);
e2e.components.TimePicker.toField().clear().type(to);
e2e.components.TimePicker.applyTimeRange().click();
};
+25
View File
@@ -0,0 +1,25 @@
import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen';
import { e2e } from '..';
import { fromBaseUrl } from '../support/url';
const defaultUserPreferences = {
timezone: '', // "Default" option
} as const; // TODO: when we update typescript >4.9 change to `as const satisfies UserPreferencesDTO`
// Only accept preferences we have defaults for as arguments. To allow a new preference to be set, add a default for it
type UserPreferences = Pick<UserPreferencesDTO, keyof typeof defaultUserPreferences>;
export function setUserPreferences(prefs: UserPreferences) {
e2e.setScenarioContext({ hasChangedUserPreferences: prefs !== defaultUserPreferences });
return cy.request({
method: 'PUT',
url: fromBaseUrl('/api/user/preferences'),
body: prefs,
});
}
export function setDefaultUserPreferences() {
return setUserPreferences(defaultUserPreferences);
}