mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
E2E: Add support for data-test-id over aria labels and add importDashboard flow (#36483)
* E2E: Add support for data-testids and not just aria-labels.
This commit is contained in:
parent
ef689d0c24
commit
f32d200fc0
@ -160,3 +160,41 @@ describe('List test', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Aria-Labels vs data-testid
|
||||||
|
Our selectors are set up to work with both aria-labels and data-testid attributes. Aria-labels help assistive technologies such as screenreaders identify interactive elements of a page for our users.
|
||||||
|
|
||||||
|
A good example of a time to use an aria-label might be if you have a button with an X to close:
|
||||||
|
```
|
||||||
|
<button aria-label="close">X<button>
|
||||||
|
```
|
||||||
|
It might be clear visually that the X closes the modal, but audibly it would not be clear for example.
|
||||||
|
```
|
||||||
|
<button aria-label="close">Close<button>
|
||||||
|
```
|
||||||
|
The above example for example might read aloud to a user "Close, Close" or something similar.
|
||||||
|
|
||||||
|
However adding aria-labels to elements that are already clearly labeled or not interactive can be confusing and redundant for users with assistive technologies.
|
||||||
|
|
||||||
|
In such cases rather than adding unnecessary aria-labels to components so as to make them selectable for testing, it is preferable to use a data attribute that would not be read aloud with an assistive technology for example:
|
||||||
|
|
||||||
|
```
|
||||||
|
<button data-testid="modal-close-button">Close<button>
|
||||||
|
```
|
||||||
|
|
||||||
|
We have added support for this in our selectors, to use:
|
||||||
|
|
||||||
|
Prefix your selector string with "data-testid":
|
||||||
|
```typescript
|
||||||
|
export const Components = {
|
||||||
|
Login: {
|
||||||
|
openButton: "data-testid-open", // this would look for a data-testid
|
||||||
|
closeButton: "close-button" // this would look for an aria-label
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
and in your component, import the selectors and add the data test id:
|
||||||
|
```
|
||||||
|
<button data-testid={Selectors.Components.Login.openButton}>
|
||||||
|
```
|
122
e2e/suite1/specs/import-dashboard.spec.ts
Normal file
122
e2e/suite1/specs/import-dashboard.spec.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { e2e } from '@grafana/e2e';
|
||||||
|
|
||||||
|
e2e.scenario({
|
||||||
|
describeName: 'Import Dashboard Test',
|
||||||
|
itName: 'Ensure you can import a dashboard',
|
||||||
|
addScenarioDataSource: false,
|
||||||
|
addScenarioDashBoard: false,
|
||||||
|
skipScenario: false,
|
||||||
|
scenario: () => {
|
||||||
|
e2e.flows.importDashboard(TEST_DASHBOARD);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const TEST_DASHBOARD = {
|
||||||
|
annotations: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
builtIn: 1,
|
||||||
|
datasource: '-- Grafana --',
|
||||||
|
enable: true,
|
||||||
|
hide: true,
|
||||||
|
iconColor: 'rgba(0, 211, 255, 1)',
|
||||||
|
name: 'Annotations & Alerts',
|
||||||
|
type: 'dashboard',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
editable: true,
|
||||||
|
gnetId: null,
|
||||||
|
graphTooltip: 0,
|
||||||
|
id: 74,
|
||||||
|
links: [],
|
||||||
|
panels: [
|
||||||
|
{
|
||||||
|
datasource: null,
|
||||||
|
fieldConfig: {
|
||||||
|
defaults: {
|
||||||
|
color: {
|
||||||
|
mode: 'palette-classic',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
axisLabel: '',
|
||||||
|
axisPlacement: 'auto',
|
||||||
|
barAlignment: 0,
|
||||||
|
drawStyle: 'line',
|
||||||
|
fillOpacity: 0,
|
||||||
|
gradientMode: 'none',
|
||||||
|
hideFrom: {
|
||||||
|
legend: false,
|
||||||
|
tooltip: false,
|
||||||
|
viz: false,
|
||||||
|
},
|
||||||
|
lineInterpolation: 'linear',
|
||||||
|
lineWidth: 1,
|
||||||
|
pointSize: 5,
|
||||||
|
scaleDistribution: {
|
||||||
|
type: 'linear',
|
||||||
|
},
|
||||||
|
showPoints: 'auto',
|
||||||
|
spanNulls: false,
|
||||||
|
stacking: {
|
||||||
|
group: 'A',
|
||||||
|
mode: 'none',
|
||||||
|
},
|
||||||
|
thresholdsStyle: {
|
||||||
|
mode: 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mappings: [],
|
||||||
|
thresholds: {
|
||||||
|
mode: 'absolute',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
color: 'green',
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'red',
|
||||||
|
value: 80,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrides: [],
|
||||||
|
},
|
||||||
|
gridPos: {
|
||||||
|
h: 9,
|
||||||
|
w: 12,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
id: 2,
|
||||||
|
options: {
|
||||||
|
legend: {
|
||||||
|
calcs: [],
|
||||||
|
displayMode: 'list',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'single',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: 'Panel Title',
|
||||||
|
type: 'timeseries',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
schemaVersion: 30,
|
||||||
|
style: 'dark',
|
||||||
|
tags: [],
|
||||||
|
templating: {
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
from: '2021-06-30T04:00:00.000Z',
|
||||||
|
to: '2021-07-02T03:59:59.000Z',
|
||||||
|
},
|
||||||
|
timepicker: {},
|
||||||
|
timezone: '',
|
||||||
|
title: 'An imported dashboard for e2e tests',
|
||||||
|
uid: '6V0Nzyz7k',
|
||||||
|
version: 1,
|
||||||
|
};
|
@ -1,3 +1,9 @@
|
|||||||
|
// NOTE: by default Component string selectors are set up to be aria-labels,
|
||||||
|
// however there are many cases where your component may not need an aria-label
|
||||||
|
// (a <button> with clear text, for example, does not need an aria-label as it's already labeled)
|
||||||
|
// but you still might need to select it for testing,
|
||||||
|
// in that case please add the attribute data-test-id={selector} in the component and
|
||||||
|
// prefix your selector string with 'data-test-id' so that when create the selectors we know to search for it on the right attribute
|
||||||
export const Components = {
|
export const Components = {
|
||||||
TimePicker: {
|
TimePicker: {
|
||||||
openButton: 'TimePicker Open Button',
|
openButton: 'TimePicker Open Button',
|
||||||
@ -222,4 +228,12 @@ export const Components = {
|
|||||||
CodeEditor: {
|
CodeEditor: {
|
||||||
container: 'Code editor container',
|
container: 'Code editor container',
|
||||||
},
|
},
|
||||||
|
DashboardImportPage: {
|
||||||
|
textarea: 'data-testid-import-dashboard-textarea',
|
||||||
|
submit: 'data-testid-load-dashboard',
|
||||||
|
},
|
||||||
|
ImportDashboardForm: {
|
||||||
|
name: 'data-testid-import-dashboard-title',
|
||||||
|
submit: 'data-testid-import-dashboard-submit',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
55
packages/grafana-e2e/src/flows/importDashboard.ts
Normal file
55
packages/grafana-e2e/src/flows/importDashboard.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { DeleteDashboardConfig } from '.';
|
||||||
|
import { e2e } from '../index';
|
||||||
|
import { fromBaseUrl, getDashboardUid } from '../support/url';
|
||||||
|
|
||||||
|
type Panel = {
|
||||||
|
title: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Dashboard = { title: string; panels: Panel[]; [key: string]: unknown };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test a datasource by quickly importing a test dashboard for it
|
||||||
|
* @param dashboardToImport a sample dashboard
|
||||||
|
*/
|
||||||
|
export const importDashboard = (dashboardToImport: Dashboard) => {
|
||||||
|
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().click({ force: true }).invoke('val', JSON.stringify(dashboardToImport));
|
||||||
|
e2e.components.DashboardImportPage.submit().click({ force: true });
|
||||||
|
e2e.components.ImportDashboardForm.name().click({ force: true }).clear().type(dashboardToImport.title);
|
||||||
|
e2e.components.ImportDashboardForm.submit().click({ force: true });
|
||||||
|
e2e().wait(3000);
|
||||||
|
|
||||||
|
// 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 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// inspect first panel and verify data has been processed for it
|
||||||
|
e2e.components.Panels.Panel.title(dashboardToImport.panels[0].title).click({ force: true });
|
||||||
|
e2e.components.Panels.Panel.headerItems('Inspect').click({ force: true });
|
||||||
|
e2e.components.Tab.title('JSON').click({ force: true });
|
||||||
|
e2e().wait(3000);
|
||||||
|
e2e.components.PanelInspector.Json.content().contains('Panel JSON').click({ force: true });
|
||||||
|
e2e().wait(3000);
|
||||||
|
e2e.components.Select.option().contains('Data').click({ force: true });
|
||||||
|
e2e().wait(3000);
|
||||||
|
|
||||||
|
// 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().contains('"state": "Done"');
|
||||||
|
};
|
@ -12,6 +12,7 @@ export * from './openPanelMenuItem';
|
|||||||
export * from './revertAllChanges';
|
export * from './revertAllChanges';
|
||||||
export * from './saveDashboard';
|
export * from './saveDashboard';
|
||||||
export * from './selectOption';
|
export * from './selectOption';
|
||||||
|
export * from './importDashboard';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
VISUALIZATION_ALERT_LIST,
|
VISUALIZATION_ALERT_LIST,
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
export interface SelectorApi {
|
export interface SelectorApi {
|
||||||
fromAriaLabel: (selector: string) => string;
|
fromAriaLabel: (selector: string) => string;
|
||||||
|
fromDataTestId: (selector: string) => string;
|
||||||
fromSelector: (selector: string) => string;
|
fromSelector: (selector: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Selector: SelectorApi = {
|
export const Selector: SelectorApi = {
|
||||||
fromAriaLabel: (selector: string) => `[aria-label="${selector}"]`,
|
fromAriaLabel: (selector: string) => `[aria-label="${selector}"]`,
|
||||||
|
fromDataTestId: (selector: string) => `[data-testid="${selector}"]`,
|
||||||
fromSelector: (selector: string) => selector,
|
fromSelector: (selector: string) => selector,
|
||||||
};
|
};
|
||||||
|
@ -61,7 +61,11 @@ const processSelectors = <S extends Selectors>(e2eObjects: E2EFunctions<S>, sele
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
e2eObjects[key] = (options?: CypressOptions) => {
|
e2eObjects[key] = (options?: CypressOptions) => {
|
||||||
logOutput(value);
|
logOutput(value);
|
||||||
return e2e().get(Selector.fromAriaLabel(value), options);
|
const selector = value.startsWith('data-testid')
|
||||||
|
? Selector.fromDataTestId(value)
|
||||||
|
: Selector.fromAriaLabel(value);
|
||||||
|
|
||||||
|
return e2e().get(selector, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -81,8 +85,10 @@ const processSelectors = <S extends Selectors>(e2eObjects: E2EFunctions<S>, sele
|
|||||||
// the input can be (text) or (options)
|
// the input can be (text) or (options)
|
||||||
if (arguments.length === 1) {
|
if (arguments.length === 1) {
|
||||||
if (typeof textOrOptions === 'string') {
|
if (typeof textOrOptions === 'string') {
|
||||||
const ariaText = value(textOrOptions);
|
const selectorText = value(textOrOptions);
|
||||||
const selector = Selector.fromAriaLabel(ariaText);
|
const selector = selectorText.startsWith('data-testid')
|
||||||
|
? Selector.fromDataTestId(selectorText)
|
||||||
|
: Selector.fromAriaLabel(selectorText);
|
||||||
|
|
||||||
logOutput(selector);
|
logOutput(selector);
|
||||||
return e2e().get(selector);
|
return e2e().get(selector);
|
||||||
@ -94,9 +100,12 @@ const processSelectors = <S extends Selectors>(e2eObjects: E2EFunctions<S>, sele
|
|||||||
}
|
}
|
||||||
|
|
||||||
// the input can only be (text, options)
|
// the input can only be (text, options)
|
||||||
if (arguments.length === 2) {
|
if (arguments.length === 2 && typeof textOrOptions === 'string') {
|
||||||
const ariaText = value(textOrOptions as string);
|
const text = textOrOptions;
|
||||||
const selector = Selector.fromAriaLabel(ariaText);
|
const selectorText = value(text);
|
||||||
|
const selector = text.startsWith('data-testid')
|
||||||
|
? Selector.fromDataTestId(selectorText)
|
||||||
|
: Selector.fromAriaLabel(selectorText);
|
||||||
|
|
||||||
logOutput(selector);
|
logOutput(selector);
|
||||||
return e2e().get(selector, options);
|
return e2e().get(selector, options);
|
||||||
|
@ -2,6 +2,7 @@ import React, { FormEvent, PureComponent } from 'react';
|
|||||||
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
|
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { AppEvents, GrafanaTheme2, NavModel } from '@grafana/data';
|
import { AppEvents, GrafanaTheme2, NavModel } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
stylesFactory,
|
stylesFactory,
|
||||||
@ -121,10 +122,13 @@ class UnthemedDashboardImport extends PureComponent<Props> {
|
|||||||
required: 'Need a dashboard JSON model',
|
required: 'Need a dashboard JSON model',
|
||||||
validate: validateDashboardJson,
|
validate: validateDashboardJson,
|
||||||
})}
|
})}
|
||||||
|
data-testid={selectors.components.DashboardImportPage.textarea}
|
||||||
rows={10}
|
rows={10}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Button type="submit">Load</Button>
|
<Button type="submit" data-testid={selectors.components.DashboardImportPage.submit}>
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -14,6 +14,7 @@ import { DataSourcePicker } from '@grafana/runtime';
|
|||||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||||
import { DashboardInput, DashboardInputs, DataSourceInput, ImportDashboardDTO } from '../state/reducers';
|
import { DashboardInput, DashboardInputs, DataSourceInput, ImportDashboardDTO } from '../state/reducers';
|
||||||
import { validateTitle, validateUid } from '../utils/validation';
|
import { validateTitle, validateUid } from '../utils/validation';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
interface Props extends Pick<FormAPI<ImportDashboardDTO>, 'register' | 'errors' | 'control' | 'getValues' | 'watch'> {
|
interface Props extends Pick<FormAPI<ImportDashboardDTO>, 'register' | 'errors' | 'control' | 'getValues' | 'watch'> {
|
||||||
uidReset: boolean;
|
uidReset: boolean;
|
||||||
@ -61,6 +62,7 @@ export const ImportDashboardForm: FC<Props> = ({
|
|||||||
validate: async (v: string) => await validateTitle(v, getValues().folder.id),
|
validate: async (v: string) => await validateTitle(v, getValues().folder.id),
|
||||||
})}
|
})}
|
||||||
type="text"
|
type="text"
|
||||||
|
data-testid={selectors.components.ImportDashboardForm.name}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Folder">
|
<Field label="Folder">
|
||||||
@ -137,6 +139,7 @@ export const ImportDashboardForm: FC<Props> = ({
|
|||||||
<HorizontalGroup>
|
<HorizontalGroup>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
data-testid={selectors.components.ImportDashboardForm.submit}
|
||||||
variant={getButtonVariant(errors)}
|
variant={getButtonVariant(errors)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
|
Loading…
Reference in New Issue
Block a user