diff --git a/packages/plugin-e2e/src/fixtures/components.ts b/packages/plugin-e2e/src/fixtures/components.ts new file mode 100644 index 0000000000..479078f6dc --- /dev/null +++ b/packages/plugin-e2e/src/fixtures/components.ts @@ -0,0 +1,13 @@ +import { TestFixture } from '@playwright/test'; +import { PlaywrightArgs } from '../types'; +import { Components } from '../models/Components'; + +type ComponentsFixture = TestFixture; + +export const components: ComponentsFixture = async ( + { request, page, selectors, grafanaVersion }, + use, + testInfo +) => { + await use(new Components({ page, selectors, grafanaVersion, request, testInfo })); +}; diff --git a/packages/plugin-e2e/src/index.ts b/packages/plugin-e2e/src/index.ts index ab636c28ca..2db5341673 100644 --- a/packages/plugin-e2e/src/index.ts +++ b/packages/plugin-e2e/src/index.ts @@ -11,6 +11,7 @@ import { A11yViolationsOptions, } from './types'; import { annotationEditPage } from './fixtures/annotationEditPage'; +import { components } from './fixtures/components'; import { grafanaAPIClient } from './fixtures/grafanaAPIClient'; import { createDataSource } from './fixtures/commands/createDataSource'; import { createDataSourceConfigPage } from './fixtures/commands/createDataSourceConfigPage'; @@ -62,7 +63,9 @@ import { toHavePanelErrors } from './matchers/toHavePanelErrors'; import { DashboardPage } from './models/pages/DashboardPage'; // models +export { Components } from './models/Components'; export { DataSourcePicker } from './models/components/DataSourcePicker'; +export { ScopedComponent } from './models/components/ScopedComponent'; export { Panel } from './models/components/Panel'; export { TimeRange } from './models/components/TimeRange'; export { AnnotationEditPage } from './models/pages/AnnotationEditPage'; @@ -120,6 +123,7 @@ export const test = testWithInternal.extend({ gotoVariablePage, gotoAnnotationEditPage, gotoAlertRuleEditPage, + components, gotoDataSourceConfigPage, gotoAppConfigPage, gotoAppPage, diff --git a/packages/plugin-e2e/src/models/Components.ts b/packages/plugin-e2e/src/models/Components.ts new file mode 100644 index 0000000000..9252b7aa01 --- /dev/null +++ b/packages/plugin-e2e/src/models/Components.ts @@ -0,0 +1,28 @@ +import { PluginTestCtx } from '../types'; +import { DataSourcePicker } from './components/DataSourcePicker'; +import { TimeRange } from './components/TimeRange'; + +/** + * Factory for components that are not attached to a specific page. + * + * Use this when you need to interact with a Grafana UI component on a page + * that is not covered by one of the page fixtures (e.g. {@link PanelEditPage} + * or {@link ExplorePage}). + * + * To scope a component to a sub-tree of the DOM, use `within(root)`: + * + * @example + * ```typescript + * await components.dataSourcePicker.set('prom'); + * await components.dataSourcePicker.within(panel).set('prom'); + * ``` + */ +export class Components { + readonly dataSourcePicker: DataSourcePicker; + readonly timeRangePicker: TimeRange; + + constructor(ctx: PluginTestCtx) { + this.dataSourcePicker = new DataSourcePicker(ctx); + this.timeRangePicker = new TimeRange(ctx); + } +} diff --git a/packages/plugin-e2e/src/models/components/DataSourcePicker.ts b/packages/plugin-e2e/src/models/components/DataSourcePicker.ts index 816a18708d..4d3d39b8e5 100644 --- a/packages/plugin-e2e/src/models/components/DataSourcePicker.ts +++ b/packages/plugin-e2e/src/models/components/DataSourcePicker.ts @@ -1,16 +1,8 @@ import * as semver from 'semver'; -import { Locator, expect } from '@playwright/test'; -import { PluginTestCtx } from '../../types'; -import { GrafanaPage } from '../pages/GrafanaPage'; - -export class DataSourcePicker extends GrafanaPage { - constructor( - ctx: PluginTestCtx, - private root?: Locator - ) { - super(ctx); - } +import { expect } from '@playwright/test'; +import { ScopedComponent } from './ScopedComponent'; +export class DataSourcePicker extends ScopedComponent { /** * Sets the data source picker to the provided name */ diff --git a/packages/plugin-e2e/src/models/components/ScopedComponent.ts b/packages/plugin-e2e/src/models/components/ScopedComponent.ts new file mode 100644 index 0000000000..b4be3a66da --- /dev/null +++ b/packages/plugin-e2e/src/models/components/ScopedComponent.ts @@ -0,0 +1,25 @@ +import { Locator } from '@playwright/test'; +import { PluginTestCtx } from '../../types'; +import { GrafanaPage } from '../pages/GrafanaPage'; + +/** + * Base class for components that live at the page level but can optionally + * be scoped to a sub-tree of the DOM via `within()`. + */ +export abstract class ScopedComponent extends GrafanaPage { + constructor( + ctx: PluginTestCtx, + protected readonly root?: Locator + ) { + super(ctx); + } + + /** + * Returns a new instance of this component scoped to the given root locator. + * Mirrors Playwright's `locator.locator(...)` chaining pattern. + */ + within(root: Locator): this { + const Ctor = this.constructor as new (ctx: PluginTestCtx, root?: Locator) => this; + return new Ctor(this.ctx, root); + } +} diff --git a/packages/plugin-e2e/src/models/components/TimeRange.ts b/packages/plugin-e2e/src/models/components/TimeRange.ts index 80aa68d2b3..41bcf58f34 100644 --- a/packages/plugin-e2e/src/models/components/TimeRange.ts +++ b/packages/plugin-e2e/src/models/components/TimeRange.ts @@ -1,19 +1,15 @@ import * as semver from 'semver'; -import { PluginTestCtx, TimeRangeArgs } from '../../types'; -import { GrafanaPage } from '../pages/GrafanaPage'; - -export class TimeRange extends GrafanaPage { - constructor(ctx: PluginTestCtx) { - super(ctx); - } +import { TimeRangeArgs } from '../../types'; +import { ScopedComponent } from './ScopedComponent'; +export class TimeRange extends ScopedComponent { /** * Opens the time picker and sets the time range to the provided values */ async set({ from, to, zone }: TimeRangeArgs) { const { TimeZonePicker, TimePicker, Select } = this.ctx.selectors.components; try { - await this.getByGrafanaSelector(TimePicker.openButton).click(); + await this.getByGrafanaSelector(TimePicker.openButton, { root: this.root }).click(); } catch (e) { // seems like in older versions of Grafana the time picker markup is rendered twice await this.ctx.page.locator('[aria-controls="TimePickerContent"]').last().click(); diff --git a/packages/plugin-e2e/src/models/pages/DashboardPage.ts b/packages/plugin-e2e/src/models/pages/DashboardPage.ts index 6b8f55d474..248c2da104 100644 --- a/packages/plugin-e2e/src/models/pages/DashboardPage.ts +++ b/packages/plugin-e2e/src/models/pages/DashboardPage.ts @@ -54,6 +54,23 @@ export class DashboardPage extends GrafanaPage { return super.navigate(url, options); } + /** + * Returns a locator for the dashboard toolbar area that contains the time range controls. + * + * - Grafana ≥ 11.1.0: resolves to `Dashboard.Controls` (scenes-based dashboard controls bar) + * - Grafana 9.4.0–11.0.x: resolves to `NavToolbar.container` + * - Grafana < 9.4.0: falls back to `.page-toolbar` + */ + get toolbar() { + const { components, pages } = this.ctx.selectors; + if (semver.gte(this.ctx.grafanaVersion, '11.1.0')) { + return this.getByGrafanaSelector(pages.Dashboard.Controls); + } + return semver.gte(this.ctx.grafanaVersion, '9.4.0') + ? this.getByGrafanaSelector(components.NavToolbar.container) + : this.ctx.page.locator('.page-toolbar'); + } + /** * Scrolls the page viewport-by-viewport to trigger below-fold panel queries. * diff --git a/packages/plugin-e2e/src/types.ts b/packages/plugin-e2e/src/types.ts index 13474be2e0..9a50bd8556 100644 --- a/packages/plugin-e2e/src/types.ts +++ b/packages/plugin-e2e/src/types.ts @@ -14,6 +14,7 @@ import { AlertRuleEditPage } from './models/pages/AlertRuleEditPage'; import { AnnotationEditPage } from './models/pages/AnnotationEditPage'; import { AppConfigPage } from './models/pages/AppConfigPage'; import { AppPage } from './models/pages/AppPage'; +import { Components } from './models/Components'; import { DashboardPage } from './models/pages/DashboardPage'; import { DataSourceConfigPage } from './models/pages/DataSourceConfigPage'; import { ExplorePage } from './models/pages/ExplorePage'; @@ -433,6 +434,22 @@ export type PluginFixture = { * You can use this in conjunction with the .toHaveNoA11yViolations matcher to assert that there are no accessibility violations on the page. */ scanForA11yViolations: (context?: AxeScanContext) => Promise; + + /** + * Factory for components that are not attached to a specific page. + * + * Use this fixture to interact with a Grafana UI component on a page that is + * not covered by other page fixtures (e.g. {@link PanelEditPage} or {@link ExplorePage}). + * + * @example + * ```typescript + * test('my test', async ({ components }) => { + * await components.dataSourcePicker.set('gdev-prometheus'); + * await components.dataSourcePicker.within(someLocator).set('gdev-tempo'); + * }); + * ``` + */ + components: Components; }; /** diff --git a/packages/plugin-e2e/tests/as-admin-user/components/components.spec.ts b/packages/plugin-e2e/tests/as-admin-user/components/components.spec.ts new file mode 100644 index 0000000000..af78d305d4 --- /dev/null +++ b/packages/plugin-e2e/tests/as-admin-user/components/components.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '../../../src'; + +test('components.dataSourcePicker should set the data source', async ({ + panelEditPage, + components, + readProvisionedDataSource, +}) => { + const ds = await readProvisionedDataSource({ fileName: 'testdatasource.yaml' }); + await components.dataSourcePicker.set(ds.name); + await expect(panelEditPage.getQueryEditorRow('A').getByRole('textbox', { name: 'Query Text' })).toBeVisible(); +}); + +test('components.dataSourcePicker.within should set the data source when scoped to a root locator', async ({ + panelEditPage, + components, + readProvisionedDataSource, + selectors, +}) => { + const ds = await readProvisionedDataSource({ fileName: 'testdatasource.yaml' }); + const root = panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.General.content); + await components.dataSourcePicker.within(root).set(ds.name); + await expect(panelEditPage.getQueryEditorRow('A').getByRole('textbox', { name: 'Query Text' })).toBeVisible(); +}); + +test('components.timeRangePicker should set the time range', async ({ + panelEditPage, + components, + selectors, +}) => { + await components.timeRangePicker.set({ from: '2020-01-01 00:00:00', to: '2020-01-02 00:00:00' }); + // older Grafana versions render the time picker twice, so we use .first() to avoid strict mode violations + const openButton = panelEditPage.getByGrafanaSelector(selectors.components.TimePicker.openButton).first(); + await expect(openButton).toContainText('2020-01-01 00:00:00'); +}); + +test('components.timeRangePicker.within should set the time range when scoped to a root locator', async ({ + gotoDashboardPage, + readProvisionedDashboard, + components, + selectors, +}) => { + const dashboard = await readProvisionedDashboard({ fileName: 'testdatasource.json' }); + const dashboardPage = await gotoDashboardPage(dashboard); + await components.timeRangePicker.within(dashboardPage.toolbar).set({ from: '2020-01-01 00:00:00', to: '2020-01-02 00:00:00' }); + const openButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton).first(); + await expect(openButton).toContainText('2020-01-01 00:00:00'); +});