diff --git a/packages/plugin-e2e/src/index.ts b/packages/plugin-e2e/src/index.ts index b14e243f6..c5718fa8f 100644 --- a/packages/plugin-e2e/src/index.ts +++ b/packages/plugin-e2e/src/index.ts @@ -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'; @@ -92,6 +94,8 @@ export const expect = baseExpect.extend({ toHaveAlert, toDisplayPreviews, toBeOK, + toHaveOption, + toHaveOptions, }); export { selectors } from '@playwright/test'; diff --git a/packages/plugin-e2e/src/matchers/toHaveOption.ts b/packages/plugin-e2e/src/matchers/toHaveOption.ts new file mode 100644 index 000000000..3a5b3f217 --- /dev/null +++ b/packages/plugin-e2e/src/matchers/toHaveOption.ts @@ -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 { + 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, + }; + } +} diff --git a/packages/plugin-e2e/src/matchers/toHaveOptions.ts b/packages/plugin-e2e/src/matchers/toHaveOptions.ts new file mode 100644 index 000000000..d0d3eef41 --- /dev/null +++ b/packages/plugin-e2e/src/matchers/toHaveOptions.ts @@ -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): Promise { + 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, + }; + } +} diff --git a/packages/plugin-e2e/src/models/components/ComponentBase.ts b/packages/plugin-e2e/src/models/components/ComponentBase.ts new file mode 100644 index 000000000..1a4cb3627 --- /dev/null +++ b/packages/plugin-e2e/src/models/components/ComponentBase.ts @@ -0,0 +1,11 @@ +import { Locator } from '@playwright/test'; + +type LocatorParams = Parameters; + +export abstract class ComponentBase { + constructor(protected readonly element: Locator) {} + + locator(selectorOrLocator: LocatorParams[0], options?: LocatorParams[1]): Locator { + return this.element.locator(selectorOrLocator, options); + } +} diff --git a/packages/plugin-e2e/src/models/components/MultiSelect.ts b/packages/plugin-e2e/src/models/components/MultiSelect.ts new file mode 100644 index 000000000..29f0e42ab --- /dev/null +++ b/packages/plugin-e2e/src/models/components/MultiSelect.ts @@ -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[1]; + +export class MultiSelect extends ComponentBase { + constructor(private ctx: PluginTestCtx, element: Locator) { + super(element); + } + + async selectOptions(values: string[], options?: OptionsType): Promise { + const menu = await openSelect(this.element, options); + + return Promise.all( + values.map((value) => { + return selectByValueOrLabel(value, menu, options); + }) + ); + } +} diff --git a/packages/plugin-e2e/src/models/components/PanelEditOptionsGroup.ts b/packages/plugin-e2e/src/models/components/PanelEditOptionsGroup.ts index 1af65b05f..562936dae 100644 --- a/packages/plugin-e2e/src/models/components/PanelEditOptionsGroup.ts +++ b/packages/plugin-e2e/src/models/components/PanelEditOptionsGroup.ts @@ -1,8 +1,9 @@ import { Locator } from '@playwright/test'; import { PluginTestCtx } from '../../types'; -import { createSelectProxy } from './SelectProxy'; 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) {} @@ -29,8 +30,12 @@ export class PanelEditOptionsGroup { return this.getNumberInput(label); } - getSelect(label: string): Locator { - return createSelectProxy(this.getByLabel(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 { diff --git a/packages/plugin-e2e/src/models/components/Select.ts b/packages/plugin-e2e/src/models/components/Select.ts new file mode 100644 index 000000000..a00d50db1 --- /dev/null +++ b/packages/plugin-e2e/src/models/components/Select.ts @@ -0,0 +1,43 @@ +import { Locator } from '@playwright/test'; +import { PluginTestCtx } from '../../types'; +import { ComponentBase } from './ComponentBase'; + +type OptionsType = Parameters[1]; + +export class Select extends ComponentBase { + constructor(private ctx: PluginTestCtx, element: Locator) { + super(element); + } + + async selectOption(values: string, options?: OptionsType): Promise { + const menu = await openSelect(this.element, options); + return selectByValueOrLabel(values, menu, options); + } +} + +export async function openSelect(select: Locator, options?: OptionsType): Promise { + 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 { + 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; +} diff --git a/packages/plugin-e2e/src/models/pages/GrafanaPage.ts b/packages/plugin-e2e/src/models/pages/GrafanaPage.ts index bc1734ac4..1aa3e1a7d 100644 --- a/packages/plugin-e2e/src/models/pages/GrafanaPage.ts +++ b/packages/plugin-e2e/src/models/pages/GrafanaPage.ts @@ -1,6 +1,5 @@ import { Locator, Request, Response } from '@playwright/test'; import { getByGrafanaSelectorOptions, GrafanaPageArgs, NavigateOptions, PluginTestCtx } from '../../types'; -import { getByGrafanaSelector } from '../utils'; /** * Base class for all Grafana pages. diff --git a/packages/plugin-e2e/src/models/utils.ts b/packages/plugin-e2e/src/models/utils.ts index 6085ed110..2e519f2c0 100644 --- a/packages/plugin-e2e/src/models/utils.ts +++ b/packages/plugin-e2e/src/models/utils.ts @@ -1,5 +1,4 @@ -import { Locator, Page } from '@playwright/test'; -import { getByGrafanaSelectorOptions } from '../types'; +import { Page } from '@playwright/test'; export const radioButtonSetChecked = async ( page: Page, @@ -13,16 +12,3 @@ export const radioButtonSetChecked = async ( await page.getByText(label, options).setChecked(checked); } }; - -export function getByGrafanaSelector( - locator: Page['locator'], - selector: string, - options?: getByGrafanaSelectorOptions -): Locator { - const startsWith = options?.startsWith ? '^' : ''; - if (selector.startsWith('data-testid')) { - return locator(`[data-testid${startsWith}="${selector}"]`); - } - - return locator(`[aria-label${startsWith}="${selector}"]`); -}