Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

E2E: Improve support to interact with panel edit options #1272

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e0abc09
Added two suggestions on how to implement the selectors for panel edi…
mckn Oct 29, 2024
f82174a
Added some tests and implemented functions for input, radio button gr…
mckn Oct 29, 2024
03ab082
Added support for select.
mckn Oct 30, 2024
95e8075
added some more tests and apis.
mckn Oct 31, 2024
03dbc35
added support for unit picker.
mckn Oct 31, 2024
6a75306
verified time zone.
mckn Oct 31, 2024
4ea35b0
wip - proxy hack.
mckn Nov 4, 2024
ef91a82
wip
mckn Nov 15, 2024
b5a1776
Changed the APIs to be playwright-like.
mckn Dec 3, 2024
6f3f4ac
added unit picker as well.
mckn Dec 3, 2024
ef666ca
removed unused ctx prop.
mckn Dec 3, 2024
3441a14
removed unused import.
mckn Dec 3, 2024
c73e46f
use all supported versions in matrix
sunker Dec 4, 2024
dd03be6
Made it work in 11.3.x
mckn Dec 4, 2024
09e628a
fixed selectors for v11.2.*
mckn Dec 5, 2024
fffa740
made it work for 11.1.*
mckn Dec 5, 2024
82629ba
Fixed for 10.3.*
mckn Dec 5, 2024
858b5fe
Removed comment.
mckn Dec 5, 2024
52fdb3d
supporting 8.5.x
mckn Dec 9, 2024
b8d32a2
Merge branch 'main' into mckn/panel-edit-options-group
mckn Dec 10, 2024
9a76a3a
removed the need of having switch async.
mckn Dec 10, 2024
64160a0
removed private function.
mckn Dec 10, 2024
7bbe0bd
renamed switched to checked.
mckn Dec 10, 2024
c36f555
added ctx.
mckn Dec 10, 2024
44c31f3
minor refactoring to have access to ctx via component base.
mckn Dec 10, 2024
bd9eac6
removed unneccessary check.
mckn Dec 10, 2024
3570835
Merge branch 'main' into mckn/panel-edit-options-group
mckn Dec 11, 2024
1138214
using selector instead.
mckn Dec 11, 2024
94798d1
Merge branch 'main' into mckn/panel-edit-options-group
mckn Dec 11, 2024
8bb16e9
allow any rgb or hex color to be selected.
mckn Dec 11, 2024
a410338
Fixed so the switch works again.
mckn Dec 11, 2024
760ad9e
chaninging to checkbox for switch in 11.4.0
mckn Dec 11, 2024
6d42378
fixed slider in 9.1.8
mckn Dec 11, 2024
57e103c
Merge branch 'main' into mckn/panel-edit-options-group
mckn Dec 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/plugin-e2e/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ services:
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_ANONYMOUS_ORG_NAME=Main Org.
- GF_AUTH_ANONYMOUS_ORG_ID=1
- GF_PANELS_ENABLE_ALPHA=true
- GOOGLE_JWT_FILE=${GOOGLE_JWT_FILE}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
Expand Down
37 changes: 37 additions & 0 deletions packages/plugin-e2e/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ import { GrafanaPage } from './models/pages/GrafanaPage';
import { VariableEditPage } from './models/pages/VariableEditPage';
import { variablePage } from './fixtures/variablePage';
import { gotoVariablePage } from './fixtures/commands/gotoVariablePage';
import { toHaveSelected } from './matchers/toHaveSelected';
import { Select } from './models/components/Select';
import { Switch } from './models/components/Switch';
import { toBeSwitched } from './matchers/toBeSwitched';
import { RadioGroup } from './models/components/RadioGroup';
import { toHaveChecked } from './matchers/toHaveChecked';
import { MultiSelect } from './models/components/MultiSelect';
import { toHaveColor } from './matchers/toHaveColor';
import { ColorPicker, SelectableColors } from './models/components/ColorPicker';

// models
export { DataSourcePicker } from './models/components/DataSourcePicker';
Expand Down Expand Up @@ -92,6 +101,10 @@ export const expect = baseExpect.extend({
toHaveAlert,
toDisplayPreviews,
toBeOK,
toHaveSelected,
toBeSwitched,
toHaveChecked,
toHaveColor,
});

export { selectors } from '@playwright/test';
Expand Down Expand Up @@ -131,6 +144,30 @@ declare global {
* Asserts that a GrafanaPage contains an alert with the specified severity. Use the options to specify the timeout and to filter the alerts.
*/
toHaveAlert(this: Matchers<unknown, GrafanaPage>, severity: AlertVariant, options?: AlertPageOptions): Promise<R>;

/**
* Asserts that a Selector has the specified value selected
*/
toHaveSelected(
select: Select | MultiSelect,
value: string | RegExp | string[] | RegExp[],
options?: ContainTextOptions
): Promise<R>;

/**
* Asserts that a Switch is on or off (on by default)
*/
toBeSwitched(target: Switch, options?: { on?: boolean; timeout?: number }): Promise<R>;

/**
* Asserts that a Radio has expected value selected
*/
toHaveChecked(radioGroup: RadioGroup, expected: string, options?: { timeout?: number }): Promise<R>;

/**
* Asserts that a color picker has expected color selected
*/
toHaveColor(colorPicker: ColorPicker, color: SelectableColors, options?: { timeout?: number }): Promise<R>;
}
}
}
25 changes: 25 additions & 0 deletions packages/plugin-e2e/src/matchers/toBeSwitched.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, MatcherReturnType } from '@playwright/test';
import { getMessage } from './utils';

import { Switch } from '../models/components/Switch';

export async function toBeSwitched(
mckn marked this conversation as resolved.
Show resolved Hide resolved
target: Switch,
options?: { on?: boolean; timeout?: number }
): Promise<MatcherReturnType> {
const expected = options?.on ?? true;
try {
await expect(target.locator()).toBeChecked({ ...options, checked: expected });

return {
pass: true,
expected,
message: () => `Value successfully selected`,
};
} catch (err: unknown) {
return {
message: () => getMessage(expected.toString(), err instanceof Error ? err.toString() : 'Unknown error'),
pass: false,
};
}
}
25 changes: 25 additions & 0 deletions packages/plugin-e2e/src/matchers/toHaveChecked.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, MatcherReturnType } from '@playwright/test';
import { getMessage } from './utils';

import { RadioGroup } from '../models/components/RadioGroup';

export async function toHaveChecked(
radioGroup: RadioGroup,
expected: string,
options?: { timeout?: number }
): Promise<MatcherReturnType> {
try {
await expect(radioGroup.locator().getByLabel(expected)).toBeChecked(options);

return {
pass: true,
expected,
message: () => `Value successfully selected`,
};
} catch (err: unknown) {
return {
message: () => getMessage(expected.toString(), err instanceof Error ? err.toString() : 'Unknown error'),
pass: false,
};
}
}
25 changes: 25 additions & 0 deletions packages/plugin-e2e/src/matchers/toHaveColor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, MatcherReturnType } from '@playwright/test';
import { getMessage } from './utils';

import { ColorPicker, SelectableColors } from '../models/components/ColorPicker';

export async function toHaveColor(
colorPicker: ColorPicker,
color: SelectableColors,
options?: { timeout?: number }
): Promise<MatcherReturnType> {
try {
await expect(colorPicker.locator().getByRole('textbox')).toHaveValue(color, options);

return {
pass: true,
expected: color,
message: () => `Value successfully selected`,
};
} catch (err: unknown) {
return {
message: () => getMessage(color, err instanceof Error ? err.toString() : 'Unknown error'),
pass: false,
};
}
}
126 changes: 126 additions & 0 deletions packages/plugin-e2e/src/matchers/toHaveSelected.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { expect, MatcherReturnType } from '@playwright/test';
import { getMessage } from './utils';
import { ContainTextOptions } from '../types';

import { Select } from '../models/components/Select';
import { MultiSelect } from '../models/components/MultiSelect';
import { UnitPicker } from '../models/components/UnitPicker';

export async function toHaveSelected(
target: Select | MultiSelect | UnitPicker,
value: string | RegExp | string[] | RegExp[],
options?: ContainTextOptions
): Promise<MatcherReturnType> {
if (target instanceof MultiSelect) {
if (Array.isArray(value)) {
return expectMultiSelectToBe(target, value);
}
return expectMultiSelectToBe(target, [value]);
}

if (target instanceof Select) {
if (Array.isArray(value)) {
throw new Error(
`Select only support a single value to be selected. You are asserting that multiple values have been selected: "${value}"`
);
}

return expectSelectToBe(target, value, options);
}

if (target instanceof UnitPicker) {
if (Array.isArray(value)) {
throw new Error(
`UnitPicker only support a single value to be selected. You are asserting that multiple values have been selected: "${value}"`
);
}
return expectUnitPickerToBe(target, value, options);
}

throw Error('Unsupported parameters passed to "toBeSelected"');
}

async function expectSelectToBe(
select: Select,
value: string | RegExp,
options?: ContainTextOptions
): Promise<MatcherReturnType> {
let actual = '';

try {
actual = await select
.locator('div[class*="-grafana-select-value-container"]')
.locator('div[class*="-singleValue"]')
.innerText(options);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there an e2e selector for this? If not, we should add one to Grafana.

One problem with this and the other new matchers is that that they don't have access to the test context. There's no way for them to check grafanaVersion and use the resolved selectors.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But IDK - the Selector class has access to the context, so maybe it can be encapsulated there instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, that is a valid concern. But I think we can fix that in the index.ts file.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ctx is available with that the selectors as well. So I will see if I can change these to selectors from the grafana-selectors package istead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How? This is fine in older versions of Grafana, but we need a stable e2e-selector in Grafana to be future proof. Looks like there's a data-testid set here. Can we use that instead of the classnames?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, I don't think we pass the data-testid when rendering the select component. Or I can't find it in the dom.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, can we add a new one then?


expect(actual).toMatch(value);

return {
pass: true,
actual: actual,
expected: value,
message: () => `Value successfully selected`,
};
} catch (err: unknown) {
return {
message: () => getMessage(value.toString(), err instanceof Error ? err.toString() : 'Unknown error'),
pass: false,
actual,
};
}
}

async function expectMultiSelectToBe(select: MultiSelect, values: Array<string | RegExp>): Promise<MatcherReturnType> {
let actual = '';

try {
const actual = await select
.locator('div[class*="-grafana-select-multi-value-container"]')
.locator('div[class*="-grafana-select-multi-value-container"] > div')
.allInnerTexts();

expect(actual).toMatchObject(values);

return {
pass: true,
actual: actual,
expected: values,
message: () => `Values successfully selected`,
};
} catch (err: unknown) {
return {
message: () => getMessage(values.join(', '), err instanceof Error ? err.toString() : 'Unknown error'),
pass: false,
actual,
expected: values,
};
}
}

async function expectUnitPickerToBe(
unitPicker: UnitPicker,
value: string | RegExp,
options?: ContainTextOptions
): Promise<MatcherReturnType> {
let actual = '';

try {
const input = unitPicker.locator().getByRole('textbox');

actual = await input.inputValue(options);
await expect(input).toHaveValue(value);

return {
pass: true,
actual: actual,
expected: value,
message: () => `Value successfully selected`,
};
} catch (err: unknown) {
return {
message: () => getMessage(value.toString(), err instanceof Error ? err.toString() : 'Unknown error'),
pass: false,
actual,
};
}
}
20 changes: 20 additions & 0 deletions packages/plugin-e2e/src/models/components/ColorPicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Locator } from '@playwright/test';
import { PluginTestCtx } from '../../types';
import { ComponentBase } from './ComponentBase';
import { SelectOptionsType } from './types';

export type SelectableColors = 'red' | 'blue' | 'orange' | 'green' | 'yellow';
mckn marked this conversation as resolved.
Show resolved Hide resolved

export class ColorPicker extends ComponentBase {
constructor(private ctx: PluginTestCtx, element: Locator) {
super(element);
}

async selectOption(color: SelectableColors, options?: SelectOptionsType): Promise<void> {
await this.element.getByRole('button').click(options);
await this.ctx.page
.locator('#grafana-portal-container')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here as well. This is fine in older versions of Grafana, but now can we add a selector to this element?

.getByRole('button', { name: `${color} color`, exact: true })
.click(options);
}
}
14 changes: 14 additions & 0 deletions packages/plugin-e2e/src/models/components/ComponentBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Locator } from '@playwright/test';

type LocatorParams = Parameters<Locator['locator']>;

export abstract class ComponentBase {
constructor(protected readonly element: Locator) {}
mckn marked this conversation as resolved.
Show resolved Hide resolved

locator(selectorOrLocator?: LocatorParams[0], options?: LocatorParams[1]): Locator {
mckn marked this conversation as resolved.
Show resolved Hide resolved
if (!selectorOrLocator) {
return this.element;
}
return this.element.locator(selectorOrLocator, options);
}
}
20 changes: 20 additions & 0 deletions packages/plugin-e2e/src/models/components/MultiSelect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Locator } from '@playwright/test';
import { openSelect, selectByValueOrLabel } from './Select';
import { ComponentBase } from './ComponentBase';
import { SelectOptionsType } from './types';

export class MultiSelect extends ComponentBase {
constructor(element: Locator) {
super(element);
}

async selectOptions(values: string[], options?: SelectOptionsType): Promise<string[]> {
const menu = await openSelect(this.element, options);

return Promise.all(
values.map((value) => {
return selectByValueOrLabel(value, menu, options);
})
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Locator } from '@playwright/test';
import { PluginTestCtx } from '../../types';
import { ColorPicker } from './ColorPicker';
import { UnitPicker } from './UnitPicker';
import { Select } from './Select';
import { MultiSelect } from './MultiSelect';
import { Switch } from './Switch';
import { gte } from 'semver';
import { RadioGroup } from './RadioGroup';

export class PanelEditOptionsGroup {
constructor(private ctx: PluginTestCtx, public readonly element: Locator, private groupLabel: string) {}

getRadio(label: string): RadioGroup {
mckn marked this conversation as resolved.
Show resolved Hide resolved
return new RadioGroup(this.getByLabel(label).getByRole('radiogroup'));
}

async getSwitch(label: string): Promise<Switch> {
if (gte(this.ctx.grafanaVersion, '11.4.0')) {
const id = await this.getByLabel(label).getByRole('switch').getAttribute('id');
mckn marked this conversation as resolved.
Show resolved Hide resolved
return new Switch(this.getByLabel(label).locator(`label[for='${id}']`));
}

const id = await this.getByLabel(label).getByRole('checkbox').getAttribute('id');
return new Switch(this.getByLabel(label).locator(`label[for='${id}']`));
}

getTextInput(label: string): Locator {
return this.getByLabel(label).getByRole('textbox');
}

getNumberInput(label: string): Locator {
return this.getByLabel(label).getByRole('spinbutton');
}

getSliderInput(label: string): Locator {
return this.getNumberInput(label);
}
mckn marked this conversation as resolved.
Show resolved Hide resolved

getSelect(label: string): Select {
return new Select(this.getByLabel(label));
}

getMultiSelect(label: string): MultiSelect {
return new MultiSelect(this.getByLabel(label));
}

getColorPicker(label: string): ColorPicker {
return new ColorPicker(this.ctx, this.getByLabel(label));
}

getUnitPicker(label: string): UnitPicker {
return new UnitPicker(this.ctx, this.getByLabel(label));
}

private getByLabel(optionLabel: string): Locator {
return this.element.getByLabel(`${this.groupLabel} ${optionLabel} field property editor`);
}
}
Loading
Loading