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

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions packages/plugin-e2e/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { GrafanaPage } from './models/pages/GrafanaPage';
import { VariableEditPage } from './models/pages/VariableEditPage';
import { variablePage } from './fixtures/variablePage';
import { gotoVariablePage } from './fixtures/commands/gotoVariablePage';
import { toHaveOption } from './matchers/toHaveOption';
import { toHaveOptions } from './matchers/toHaveOptions';

// models
export { DataSourcePicker } from './models/components/DataSourcePicker';
Expand Down Expand Up @@ -92,6 +94,8 @@ export const expect = baseExpect.extend({
toHaveAlert,
toDisplayPreviews,
toBeOK,
toHaveOption,
toHaveOptions,
});

export { selectors } from '@playwright/test';
Expand Down
35 changes: 35 additions & 0 deletions packages/plugin-e2e/src/matchers/toHaveOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, MatcherReturnType } from '@playwright/test';
import { getMessage } from './utils';
import { ContainTextOptions } from '../types';

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

export async function toHaveOption(
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);

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,
};
}
}
31 changes: 31 additions & 0 deletions packages/plugin-e2e/src/matchers/toHaveOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { expect, MatcherReturnType } from '@playwright/test';
import { getMessage } from './utils';

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

export async function toHaveOptions(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,
};
}
}
22 changes: 22 additions & 0 deletions packages/plugin-e2e/src/models/components/ColorPicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Locator } from '@playwright/test';
import { PluginTestCtx } from '../../types';

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

clear(): Promise<void> {
return this.element.locator('svg[class*="-Icon"]').click();
}

async fill(color: 'red' | 'blue' | 'orange' | 'green' | 'yellow'): Promise<void> {
await this.element.getByRole('button').click();
await this.ctx.page
.locator('#grafana-portal-container')
.getByRole('button', { name: `${color} color`, exact: true })
.click();
}

value(): Locator {
return this.element.getByRole('textbox');
}
}
11 changes: 11 additions & 0 deletions packages/plugin-e2e/src/models/components/ComponentBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Locator } from '@playwright/test';

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

export abstract class ComponentBase {
constructor(protected readonly element: Locator) {}

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

type OptionsType = Parameters<Locator['selectOption']>[1];

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

async selectOptions(values: string[], options?: OptionsType): 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,52 @@
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';

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

getRadioGroup(label: string): Locator {
return this.getByLabel(label).getByRole('radiogroup');
}

async getSwitch(label: string): Promise<Locator> {
// we need to add logic to select by a switch or a checkbox role depending on grafana version.
const id = await this.getByLabel(label).getByRole('checkbox').getAttribute('id');
return 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);
}

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

getMultiSelect(label: string): MultiSelect {
return new MultiSelect(this.ctx, 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`);
}
}
43 changes: 43 additions & 0 deletions packages/plugin-e2e/src/models/components/Select.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Locator } from '@playwright/test';
import { PluginTestCtx } from '../../types';
import { ComponentBase } from './ComponentBase';

type OptionsType = Parameters<Locator['selectOption']>[1];

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

async selectOption(values: string, options?: OptionsType): Promise<string> {
const menu = await openSelect(this.element, options);
return selectByValueOrLabel(values, menu, options);
}
}

export async function openSelect(select: Locator, options?: OptionsType): Promise<Locator> {
await select.getByRole('combobox').click(options);
return select.page().getByLabel('Select options menu', {
exact: true,
});
}

export async function selectByValueOrLabel(
labelOrValue: string,
menu: Locator,
options?: OptionsType
): Promise<string> {
if (!labelOrValue) {
throw new Error(`Could not select option: "${labelOrValue}"`);
}

const option = menu.getByRole('option', { name: labelOrValue, exact: true });
await option.click(options);
const value = await option.locator('span').textContent(options);

if (!value) {
throw new Error(`Could not select option: "${labelOrValue}"`);
}

return value;
}
52 changes: 52 additions & 0 deletions packages/plugin-e2e/src/models/components/SelectProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Locator } from '@playwright/test';

export function createSelectProxy(locator: Locator): Locator {
return new Proxy<Locator>(locator, {
get(target, prop, receiver) {
if (prop === 'selectOption') {
return selectOption.bind(target);
}
if (prop === '_selector') {
const selector = Reflect.get(target, prop, receiver);
return `${selector}\ >>\ div[class*=\"-grafana-select-value-container\"]`;
}
return Reflect.get(target, prop, receiver);
},
});
}

const selectOption: Locator['selectOption'] = async function (this: Locator, values, options) {
// Open the menu of the select component
this.getByRole('combobox').click();

if (typeof values === 'string') {
const option = this.page().getByLabel('Select options menu').getByText(values);
await option.click(options);
return [values];
}

return Promise.resolve<string[]>([]);
};

// const textContent: Locator['textContent'] = async function (this: Locator, options) {
// const singleContainer = this.locator('div[class*="-grafana-select-value-container"]');
// const isSingle = await singleContainer.isVisible();

// if (isSingle) {
// return singleContainer.locator('div[class*="-singleValue"]').innerText(options);
// }

// const multiContainer = this.locator('div[class*="-grafana-select-multi-value-container"]');
// const isMulti = await multiContainer.isVisible();

// if (isMulti) {
// const valueContainers = await multiContainer
// .locator('div[class*="-grafana-select-multi-value-container"] > div')
// .all();

// const values = await Promise.all(valueContainers.map((v) => v.innerText(options)));
// return Promise.resolve(values.join(', '));
// }

// return Promise.resolve(null);
// };
31 changes: 31 additions & 0 deletions packages/plugin-e2e/src/models/components/UnitPicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Locator } from '@playwright/test';
import { PluginTestCtx } from '../../types';

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

async open(): Promise<void> {
this.element.getByRole('textbox').click();
}

async getOption(selector: string): Promise<Locator> {
const steps = selector.split('>').map((step) => step.trim());
const container = this.ctx.page.locator('div[class="rc-cascader-menus"]');

if (steps.length === 0) {
throw new Error(`Could not find options from passed selector: ${selector}`);
}

const last = steps.pop();

for (const step of steps) {
await container.getByRole('menuitemcheckbox', { exact: true, name: step }).click();
}

return container.getByRole('menuitemcheckbox', { exact: true, name: last });
}

value(): Locator {
return this.element.getByRole('textbox');
}
}
13 changes: 8 additions & 5 deletions packages/plugin-e2e/src/models/pages/PanelEditPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,14 @@ import { TimeRange } from '../components/TimeRange';
import { Panel } from '../components/Panel';
import { radioButtonSetChecked } from '../utils';
import { DashboardPage } from './DashboardPage';
import { PanelEditOptionsGroup } from '../components/PanelEditOptionsGroup';

export class PanelEditPage extends GrafanaPage {
datasource: DataSourcePicker;
timeRange: TimeRange;
panel: Panel;

constructor(
readonly ctx: PluginTestCtx,
readonly args: DashboardEditViewArgs<string>
) {
constructor(readonly ctx: PluginTestCtx, readonly args: DashboardEditViewArgs<string>) {
super(ctx, args);
this.datasource = new DataSourcePicker(ctx);
this.timeRange = new TimeRange(ctx);
Expand Down Expand Up @@ -87,7 +85,6 @@ export class PanelEditPage extends GrafanaPage {
* Sets the visualization for the panel. This method will open the visualization picker, select the given visualization
*/
async setVisualization(visualization: Visualization | string) {
// toggle options pane if panel edit is not visible
const showPanelEditElement = this.getByGrafanaSelector('Show options pane');
const showPanelEditElementCount = await showPanelEditElement.count();
if (showPanelEditElementCount > 0) {
Expand Down Expand Up @@ -185,4 +182,10 @@ export class PanelEditPage extends GrafanaPage {

return responsePromise;
}

/** Return page object for the panel edit options group with the given label */
getOptionsGroup(label: string): PanelEditOptionsGroup {
const locator = this.ctx.page.getByLabel(`Options group ${label}`, { exact: true });
return new PanelEditOptionsGroup(this.ctx, locator, label);
}
}
Loading
Loading