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:
co-authored by
Andreas Christou
parent
e7a2c95586
commit
0f2f25c5d9
@@ -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));
|
||||
};
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { configurePanel, PartialEditPanelConfig } from './configurePanel';
|
||||
|
||||
export const editPanel = (config: Partial<PartialEditPanelConfig>) =>
|
||||
configurePanel({
|
||||
...config,
|
||||
isEdit: true,
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { setTimeRange, TimeRangeConfig } from './setTimeRange';
|
||||
|
||||
export type { TimeRangeConfig };
|
||||
|
||||
export const setDashboardTimeRange = (config: TimeRangeConfig) => setTimeRange(config);
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { E2ESelectors, Selectors, selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import * as flows from './flows';
|
||||
import { e2eFactory } from './support';
|
||||
import { benchmark } from './support/benchmark';
|
||||
import { e2eScenario, ScenarioArguments } from './support/scenario';
|
||||
import { getScenarioContext, setScenarioContext } from './support/scenarioContext';
|
||||
import * as typings from './typings';
|
||||
|
||||
const e2eObject = {
|
||||
env: (args: string) => Cypress.env(args),
|
||||
config: () => Cypress.config(),
|
||||
blobToBase64String: (blob: Blob) => Cypress.Blob.blobToBase64String(blob),
|
||||
imgSrcToBlob: (url: string) => Cypress.Blob.imgSrcToBlob(url),
|
||||
scenario: (args: ScenarioArguments) => e2eScenario(args),
|
||||
benchmark,
|
||||
pages: e2eFactory({ selectors: selectors.pages }),
|
||||
typings,
|
||||
components: e2eFactory({ selectors: selectors.components }),
|
||||
flows,
|
||||
getScenarioContext,
|
||||
setScenarioContext,
|
||||
getSelectors: <T extends Selectors>(selectors: E2ESelectors<T>) => e2eFactory({ selectors }),
|
||||
};
|
||||
|
||||
export const e2e: (() => Cypress.cy) & typeof e2eObject = Object.assign(() => cy, e2eObject);
|
||||
@@ -0,0 +1,79 @@
|
||||
import { e2e } from '../';
|
||||
|
||||
export interface BenchmarkArguments {
|
||||
name: string;
|
||||
dashboard: {
|
||||
folder: string;
|
||||
delayAfterOpening: number;
|
||||
skipPanelValidation: boolean;
|
||||
};
|
||||
repeat: number;
|
||||
duration: number;
|
||||
appStats?: {
|
||||
startCollecting?: (window: Window) => void;
|
||||
collect: (window: Window) => Record<string, unknown>;
|
||||
};
|
||||
skipScenario?: boolean;
|
||||
}
|
||||
|
||||
export const benchmark = ({
|
||||
name,
|
||||
skipScenario = false,
|
||||
repeat,
|
||||
duration,
|
||||
appStats,
|
||||
dashboard,
|
||||
}: BenchmarkArguments) => {
|
||||
if (skipScenario) {
|
||||
describe(name, () => {
|
||||
it.skip(name, () => {});
|
||||
});
|
||||
} else {
|
||||
describe(name, () => {
|
||||
before(() => {
|
||||
cy.session('login', () => e2e.flows.login(e2e.env('USERNAME'), e2e.env('PASSWORD'), true), {
|
||||
cacheAcrossSpecs: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
e2e.flows.importDashboards(dashboard.folder, 1000, dashboard.skipPanelValidation);
|
||||
});
|
||||
|
||||
afterEach(() => e2e.flows.revertAllChanges());
|
||||
|
||||
Array(repeat)
|
||||
.fill(0)
|
||||
.map((_, i) => {
|
||||
const testName = `${name}-${i}`;
|
||||
return it(testName, () => {
|
||||
e2e.flows.openDashboard();
|
||||
|
||||
e2e().wait(dashboard.delayAfterOpening);
|
||||
|
||||
if (appStats) {
|
||||
const startCollecting = appStats.startCollecting;
|
||||
if (startCollecting) {
|
||||
e2e()
|
||||
.window()
|
||||
.then((win) => startCollecting(win));
|
||||
}
|
||||
|
||||
e2e().startBenchmarking(testName);
|
||||
e2e().wait(duration);
|
||||
|
||||
e2e()
|
||||
.window()
|
||||
.then((win) => {
|
||||
e2e().stopBenchmarking(testName, appStats.collect(win));
|
||||
});
|
||||
} else {
|
||||
e2e().startBenchmarking(testName);
|
||||
e2e().wait(duration);
|
||||
e2e().stopBenchmarking(testName, {});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './localStorage';
|
||||
export * from './scenarioContext';
|
||||
export * from './selector';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { e2e } from '../index';
|
||||
|
||||
// @todo this actually returns type `Cypress.Chainable`
|
||||
const get = (key: string): any =>
|
||||
e2e()
|
||||
.wrap({ getLocalStorage: () => localStorage.getItem(key) }, { log: false })
|
||||
.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));
|
||||
@@ -0,0 +1,53 @@
|
||||
import { e2e } from '../';
|
||||
|
||||
export interface ScenarioArguments {
|
||||
describeName: string;
|
||||
itName: string;
|
||||
scenario: Function;
|
||||
skipScenario?: boolean;
|
||||
addScenarioDataSource?: boolean;
|
||||
addScenarioDashBoard?: boolean;
|
||||
loginViaApi?: boolean;
|
||||
}
|
||||
|
||||
export const e2eScenario = ({
|
||||
describeName,
|
||||
itName,
|
||||
scenario,
|
||||
skipScenario = false,
|
||||
addScenarioDataSource = false,
|
||||
addScenarioDashBoard = false,
|
||||
loginViaApi = true,
|
||||
}: ScenarioArguments) => {
|
||||
describe(describeName, () => {
|
||||
if (skipScenario) {
|
||||
it.skip(itName, () => scenario());
|
||||
} else {
|
||||
before(() => {
|
||||
cy.session(
|
||||
'login',
|
||||
() => {
|
||||
e2e.flows.login(e2e.env('USERNAME'), e2e.env('PASSWORD'), loginViaApi);
|
||||
},
|
||||
{
|
||||
cacheAcrossSpecs: true,
|
||||
}
|
||||
);
|
||||
e2e.flows.setDefaultUserPreferences();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
if (addScenarioDataSource) {
|
||||
e2e.flows.addDataSource();
|
||||
}
|
||||
if (addScenarioDashBoard) {
|
||||
e2e.flows.addDashboard();
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => e2e.flows.revertAllChanges());
|
||||
|
||||
it(itName, () => scenario());
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { DeleteDashboardConfig } from '../flows/deleteDashboard';
|
||||
import { DeleteDataSourceConfig } from '../flows/deleteDataSource';
|
||||
import { e2e } from '../index';
|
||||
|
||||
export interface ScenarioContext {
|
||||
addedDashboards: DeleteDashboardConfig[];
|
||||
addedDataSources: DeleteDataSourceConfig[];
|
||||
lastAddedDashboard: string; // @todo rename to `lastAddedDashboardTitle`
|
||||
lastAddedDashboardUid: string;
|
||||
lastAddedDataSource: string; // @todo rename to `lastAddedDataSourceName`
|
||||
lastAddedDataSourceId: string;
|
||||
hasChangedUserPreferences: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const scenarioContext: ScenarioContext = {
|
||||
addedDashboards: [],
|
||||
addedDataSources: [],
|
||||
hasChangedUserPreferences: false,
|
||||
get lastAddedDashboard() {
|
||||
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] ?? '';
|
||||
|
||||
export const getScenarioContext = (): Cypress.Chainable<ScenarioContext> =>
|
||||
e2e()
|
||||
.wrap(
|
||||
{
|
||||
getScenarioContext: (): ScenarioContext => ({ ...scenarioContext }),
|
||||
},
|
||||
{ log: false }
|
||||
)
|
||||
.invoke({ log: false }, 'getScenarioContext');
|
||||
|
||||
export const setScenarioContext = (newContext: Partial<ScenarioContext>): Cypress.Chainable<ScenarioContext> =>
|
||||
e2e()
|
||||
.wrap(
|
||||
{
|
||||
setScenarioContext: () => {
|
||||
Object.entries(newContext).forEach(([key, value]) => {
|
||||
scenarioContext[key] = value;
|
||||
});
|
||||
},
|
||||
},
|
||||
{ log: false }
|
||||
)
|
||||
.invoke({ log: false }, 'setScenarioContext');
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface SelectorApi {
|
||||
fromAriaLabel: (selector: string) => string;
|
||||
fromDataTestId: (selector: string) => string;
|
||||
fromSelector: (selector: string) => string;
|
||||
}
|
||||
|
||||
export const Selector: SelectorApi = {
|
||||
fromAriaLabel: (selector: string) => `[aria-label="${selector}"]`,
|
||||
fromDataTestId: (selector: string) => `[data-testid="${selector}"]`,
|
||||
fromSelector: (selector: string) => selector,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { CssSelector, FunctionSelector, Selectors, StringSelector, UrlSelector } from '@grafana/e2e-selectors';
|
||||
|
||||
import { e2e } from '../index';
|
||||
|
||||
import { Selector } from './selector';
|
||||
import { fromBaseUrl } from './url';
|
||||
|
||||
export type VisitFunction = (args?: string, queryParams?: object) => Cypress.Chainable<Window>;
|
||||
export type E2EVisit = { visit: VisitFunction };
|
||||
export type E2EFunction = ((text?: string, options?: CypressOptions) => Cypress.Chainable<JQuery<HTMLElement>>) &
|
||||
E2EFunctionWithOnlyOptions;
|
||||
export type E2EFunctionWithOnlyOptions = (options?: CypressOptions) => Cypress.Chainable<JQuery<HTMLElement>>;
|
||||
|
||||
export type TypeSelectors<S> = S extends StringSelector
|
||||
? E2EFunctionWithOnlyOptions
|
||||
: S extends FunctionSelector
|
||||
? E2EFunction
|
||||
: S extends CssSelector
|
||||
? E2EFunction
|
||||
: S extends UrlSelector
|
||||
? E2EVisit & Omit<E2EFunctions<S>, 'url'>
|
||||
: S extends Record<any, any>
|
||||
? E2EFunctions<S>
|
||||
: S;
|
||||
|
||||
export type E2EFunctions<S extends Selectors> = {
|
||||
[P in keyof S]: TypeSelectors<S[P]>;
|
||||
};
|
||||
|
||||
export type E2EObjects<S extends Selectors> = E2EFunctions<S>;
|
||||
|
||||
export type E2EFactoryArgs<S extends Selectors> = { selectors: S };
|
||||
|
||||
export type CypressOptions = Partial<Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow>;
|
||||
|
||||
const processSelectors = <S extends Selectors>(e2eObjects: E2EFunctions<S>, selectors: S): E2EFunctions<S> => {
|
||||
const logOutput = (data: any) => e2e().logToConsole('Retrieving Selector:', data);
|
||||
const keys = Object.keys(selectors);
|
||||
for (let index = 0; index < keys.length; index++) {
|
||||
const key = keys[index];
|
||||
const value = selectors[key];
|
||||
|
||||
if (key === 'url') {
|
||||
// @ts-ignore
|
||||
e2eObjects['visit'] = (args?: string, queryParams?: object) => {
|
||||
let parsedUrl = '';
|
||||
if (typeof value === 'string') {
|
||||
parsedUrl = fromBaseUrl(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'function' && args) {
|
||||
parsedUrl = fromBaseUrl(value(args));
|
||||
}
|
||||
|
||||
e2e().logToConsole('Visiting', parsedUrl);
|
||||
if (queryParams) {
|
||||
return e2e().visit({ url: parsedUrl, qs: queryParams });
|
||||
} else {
|
||||
return e2e().visit(parsedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// @ts-ignore
|
||||
e2eObjects[key] = (options?: CypressOptions) => {
|
||||
logOutput(value);
|
||||
const selector = value.startsWith('data-testid')
|
||||
? Selector.fromDataTestId(value)
|
||||
: Selector.fromAriaLabel(value);
|
||||
|
||||
return e2e().get(selector, options);
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'function') {
|
||||
// @ts-ignore
|
||||
e2eObjects[key] = function (textOrOptions?: string | CypressOptions, options?: CypressOptions) {
|
||||
// the input can only be ()
|
||||
if (arguments.length === 0) {
|
||||
const selector = value(undefined as unknown as string);
|
||||
|
||||
logOutput(selector);
|
||||
return e2e().get(selector);
|
||||
}
|
||||
|
||||
// the input can be (text) or (options)
|
||||
if (arguments.length === 1) {
|
||||
if (typeof textOrOptions === 'string') {
|
||||
const selectorText = value(textOrOptions);
|
||||
const selector = selectorText.startsWith('data-testid')
|
||||
? Selector.fromDataTestId(selectorText)
|
||||
: Selector.fromAriaLabel(selectorText);
|
||||
|
||||
logOutput(selector);
|
||||
return e2e().get(selector);
|
||||
}
|
||||
const selector = value(undefined as unknown as string);
|
||||
|
||||
logOutput(selector);
|
||||
return e2e().get(selector, textOrOptions);
|
||||
}
|
||||
|
||||
// the input can only be (text, options)
|
||||
if (arguments.length === 2 && typeof textOrOptions === 'string') {
|
||||
const text = textOrOptions;
|
||||
const selectorText = value(text);
|
||||
const selector = text.startsWith('data-testid')
|
||||
? Selector.fromDataTestId(selectorText)
|
||||
: Selector.fromAriaLabel(selectorText);
|
||||
|
||||
logOutput(selector);
|
||||
return e2e().get(selector, options);
|
||||
}
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
// @ts-ignore
|
||||
e2eObjects[key] = processSelectors({}, value);
|
||||
}
|
||||
}
|
||||
|
||||
return e2eObjects;
|
||||
};
|
||||
|
||||
export const e2eFactory = <S extends Selectors>({ selectors }: E2EFactoryArgs<S>): E2EObjects<S> => {
|
||||
const e2eObjects: E2EFunctions<S> = {} as E2EFunctions<S>;
|
||||
processSelectors(e2eObjects, selectors);
|
||||
|
||||
return { ...e2eObjects };
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { e2e } from '../index';
|
||||
|
||||
const getBaseUrl = () => e2e.env('BASE_URL') || e2e.config().baseUrl || 'http://localhost:3000';
|
||||
|
||||
export const fromBaseUrl = (url = '') => new URL(url, getBaseUrl()).href;
|
||||
|
||||
export const getDashboardUid = (url: string): string => {
|
||||
const matches = new URL(url).pathname.match(/\/d\/([^/]+)/);
|
||||
if (!matches) {
|
||||
throw new Error(`Couldn't parse uid from ${url}`);
|
||||
} else {
|
||||
return matches[1];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { undo } from './undo';
|
||||
@@ -0,0 +1,19 @@
|
||||
// https://nodejs.org/api/os.html#os_os_platform
|
||||
enum Platform {
|
||||
osx = 'darwin',
|
||||
windows = 'win32',
|
||||
linux = 'linux',
|
||||
aix = 'aix',
|
||||
freebsd = 'freebsd',
|
||||
openbsd = 'openbsd',
|
||||
sunos = 'sunos',
|
||||
}
|
||||
|
||||
export const undo = () => {
|
||||
switch (Cypress.platform) {
|
||||
case Platform.osx:
|
||||
return '{cmd}z';
|
||||
default:
|
||||
return '{ctrl}z';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user