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

Plugin E2E: Make it possible to test query editor in panel edit page #551

Merged
merged 25 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from 14 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
6 changes: 4 additions & 2 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
fail-fast: false
matrix:
GRAFANA_VERSION: ['latest', '10.0.5', '9.5.5', '9.2.5']
GOOGLE_SHEETS_VERSION: ['1.2.4', '1.2.0']
name: E2E Tests - Grafana@${{ matrix.GRAFANA_VERSION }} GoogleSheets@${{ matrix.GOOGLE_SHEETS_VERSION }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
Expand All @@ -31,7 +33,7 @@ jobs:
run: npx playwright install --with-deps chromium

- name: Start Grafana
run: docker run --rm -d -p 3000:3000 --name=grafana --env GF_INSTALL_PLUGINS=grafana-googlesheets-datasource grafana/grafana:${{ matrix.GRAFANA_VERSION }}; sleep 30
run: docker run --rm -d -p 3000:3000 --name=grafana --env "GF_INSTALL_PLUGINS=grafana-googlesheets-datasource ${{matrix.GOOGLE_SHEETS_VERSION}}" grafana/grafana:${{ matrix.GRAFANA_VERSION }}; sleep 30

- name: Run Playwright tests
run: npm run playwright:test --w @grafana/plugin-e2e
Expand All @@ -41,6 +43,6 @@ jobs:
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report-${{ matrix.GRAFANA_VERSION }}
name: playwright-report-Grafana${{ matrix.GRAFANA_VERSION }}-GoogleSheets${{ matrix.GOOGLE_SHEETS_VERSION }}
path: packages/plugin-e2e/playwright-report/
retention-days: 30
2 changes: 1 addition & 1 deletion packages/plugin-e2e/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
grafana:
image: grafana/${GRAFANA_IMAGE:-grafana-enterprise}:${GRAFANA_VERSION:-main}
environment:
- GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-googlesheets-datasource
- GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-googlesheets-datasource 1.2.4
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_ANONYMOUS_ORG_NAME=Main Org.
Expand Down
13 changes: 11 additions & 2 deletions packages/plugin-e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,23 @@ export default defineConfig<PluginOptions>({
name: 'authenticate',
testMatch: [/.*auth\.setup\.ts/],
},
// 2. Run all tests in parallel with Chrome.
// 2. Create a datasource that can be used across tests (should use provsiioning DS instead, but currently not working in CI)
{
name: 'setupDatasource',
use: {
storageState: 'playwright/.auth/user.json',
},
testMatch: [/.*datasource\.setup\.ts/],
dependencies: ['authenticate'],
},
// 3. Run all tests in parallel using Chrome.
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['authenticate'],
dependencies: ['authenticate', 'setupDatasource'],
},
],
});
46 changes: 43 additions & 3 deletions packages/plugin-e2e/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { test as base, expect as baseExpect } from '@playwright/test';
import { test as base, expect as baseExpect, selectors } from '@playwright/test';
import { E2ESelectors } from './e2e-selectors/types';
import fixtures from './fixtures';
import { DataSourceConfigPage } from './models';
import matchers from './matchers';
import { CreateDataSourcePageArgs } from './types';
import { CreateDataSourceArgs, CreateDataSourcePageArgs, DataSource } from './types';
import { PanelEditPage, GrafanaPage, DataSourceConfigPage, EmptyDashboardPage } from './models';
import { grafanaE2ESelectorEngine } from './selectorEngine';

export type PluginOptions = {
selectorRegistration: void;
Expand All @@ -23,6 +24,25 @@ export type PluginFixture = {
*/
selectors: E2ESelectors;

/**
* Isolated {@link EmptyDashboardPage} instance for each test.
*
* Navigates to a new dashboard page.
*/
emptyDashboardPage: EmptyDashboardPage;

/**
* Isolated {@link PanelEditPage} instance for each test.
*
* Navigates to a new dashboard page and adds a new panel.
*
sunker marked this conversation as resolved.
Show resolved Hide resolved
* Use {@link PanelEditPage.setVisualization} to change the visualization
* Use {@link PanelEditPage.datasource.set} to change the
* Use {@link ExplorePage.getQueryEditorEditorRow} to retrieve the query
sunker marked this conversation as resolved.
Show resolved Hide resolved
* editor row locator for a given query refId
*/
panelEditPage: PanelEditPage;

/**
* Fixture command that will create an isolated DataSourceConfigPage instance for a given data source type.
*
Expand All @@ -31,6 +51,15 @@ export type PluginFixture = {
*/
createDataSourceConfigPage: (args: CreateDataSourcePageArgs) => Promise<DataSourceConfigPage>;

/**
* Fixture command that creates a data source via the Grafana API.
*
* If you have tests that depend on the the existance of a data source,
* you may use this command in a setup project. Read more about setup projects
* here: https://playwright.dev/docs/auth#basic-shared-account-in-all-tests
*/
Copy link
Collaborator

@leventebalogh leventebalogh Nov 24, 2023

Choose a reason for hiding this comment

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

Just an idea: I think this comment is ok as it is and already really useful, however maybe in the future we could add some Grafana-related examples which we could link to from here as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes 100%! Actually this comment was made before I knew that it's possible to use env variables in provisioning files. That kind of makes this comment obsolete. I'll change it. But we should definitely have examples of using provisioning with (and without) secrets in plugin-examples eventually.

createDataSource: (args: CreateDataSourceArgs) => Promise<DataSource>;

/**
* Fixture command that login to Grafana using the Grafana API.
* If the same credentials should be used in every test,
Expand Down Expand Up @@ -63,4 +92,15 @@ export const test = base.extend<PluginFixture & PluginOptions>(fixtures);

export const expect = baseExpect.extend(matchers);

/** Register a custom selector engine that resolves locators for Grafana E2E selectors
*
* The same functionality is available in the {@link GrafanaPage.getByTestIdOrAriaLabel} method. However,
* by registering the selector engine, one can resolve locators by Grafana E2E selectors also within a locator.
*
* Example:
* const queryEditorRow = await panelEditPage.getQueryEditorRow('A'); // returns a locator
* queryEditorRow.locator(`selector=${selectors.components.TimePicker.openButton}`).click();
* */
selectors.register('selector', grafanaE2ESelectorEngine);

export { selectors } from '@playwright/test';
2 changes: 1 addition & 1 deletion packages/plugin-e2e/src/e2e-selectors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type E2ESelectors = {

export type APIs = {
DataSource: {
getResource: string;
resource: string;
healthCheck: string;
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const createDataSourceViaAPI = async (
const text = await createDsReq.text();
const status = await createDsReq.status();
if (status === 200) {
console.log('Data source created: ', name);
console.log('Data source created: ', dsName);
return createDsReq.json().then((r) => r.datasource);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const createDataSourceConfigPage: CreateDataSourceConfigPageFixture = async (
await datasourceConfigPage.goto();
return datasourceConfigPage;
});
deleteDataSource && datasourceConfigPage?.deleteDataSource();
deleteDataSource && (await datasourceConfigPage?.deleteDataSource());
};

export default createDataSourceConfigPage;
17 changes: 17 additions & 0 deletions packages/plugin-e2e/src/fixtures/emptyDashboardPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TestFixture, expect } from '@playwright/test';
import { PluginFixture, PluginOptions } from '../api';
import { EmptyDashboardPage } from '../models';
import { PlaywrightCombinedArgs } from './types';

type EmptyDashboardPageFixture = TestFixture<
EmptyDashboardPage,
PluginFixture & PluginOptions & PlaywrightCombinedArgs
>;

const emptyDashboardPage: EmptyDashboardPageFixture = async ({ page, request, selectors, grafanaVersion }, use) => {
const emptyDashboardPage = new EmptyDashboardPage({ page, selectors, grafanaVersion, request }, expect);
await emptyDashboardPage.goto();
await use(emptyDashboardPage);
};

export default emptyDashboardPage;
6 changes: 6 additions & 0 deletions packages/plugin-e2e/src/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import grafanaVersion from './grafanaVersion';
import selectors from './selectors';
import login from './commands/login';
import createDataSourceConfigPage from './commands/createDataSourceConfigPage';
import panelEditPage from './panelEditPage';
import createDataSource from './commands/createDataSource';
import emptyDashboardPage from './emptyDashboardPage';

export default {
selectors,
grafanaVersion,
login,
createDataSourceConfigPage,
panelEditPage,
createDataSource,
emptyDashboardPage,
};
13 changes: 13 additions & 0 deletions packages/plugin-e2e/src/fixtures/panelEditPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { TestFixture } from '@playwright/test';
import { PluginFixture, PluginOptions } from '../api';
import { PanelEditPage } from '../models';
import { PlaywrightCombinedArgs } from './types';

type PanelEditPageFixture = TestFixture<PanelEditPage, PluginFixture & PluginOptions & PlaywrightCombinedArgs>;

const panelEditPage: PanelEditPageFixture = async ({ emptyDashboardPage }, use) => {
const panelEditPage = await emptyDashboardPage.addPanel();
await use(panelEditPage);
};

export default panelEditPage;
2 changes: 2 additions & 0 deletions packages/plugin-e2e/src/matchers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import toBeOK from './toBeOK';
import toHavePanelError from './toHavePanelError';

export default {
toBeOK,
toHavePanelError,
};
26 changes: 26 additions & 0 deletions packages/plugin-e2e/src/matchers/toHavePanelError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect } from '@playwright/test';
import { PanelError } from '../types';
import { getMessage } from './utils';

const toHavePanelError = async (panelError: PanelError, options?: { timeout?: number }) => {
let pass = true;
let actual;
let message: any = 'A panel error to be displayed';

try {
const numberOfErrors = await panelError.getPanelError().count();
await expect(numberOfErrors).toBe(1);
} catch (_) {
message = getMessage(message, 'No panel error was found on the page');
actual = await panelError.getPanelError().count();
pass = false;
}

return {
message: () => message,
pass,
actual,
};
};

export default toHavePanelError;
64 changes: 64 additions & 0 deletions packages/plugin-e2e/src/models/DashboardPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const gte = require('semver/functions/gte');

import { GotoDashboardArgs, PluginTestCtx } from '../types';

import { Expect } from '@playwright/test';
import { DataSourcePicker } from './DataSourcePicker';
import { GrafanaPage } from './GrafanaPage';
import { PanelEditPage } from './PanelEditPage';
import { TimeRange } from './TimeRange';

export class DashboardPage extends GrafanaPage {
dataSourcePicker: any;
timeRange: TimeRange;

constructor(ctx: PluginTestCtx, expect: Expect<any>, protected readonly dashboardUid?: string) {
super(ctx, expect);
this.dataSourcePicker = new DataSourcePicker(ctx, expect);
this.timeRange = new TimeRange(ctx, this.expect);
}

async goto(opts?: GotoDashboardArgs) {
let url = this.ctx.selectors.pages.Dashboard.url(opts?.uid ?? this.dashboardUid ?? '');
if (opts?.queryParams) {
url += `?${opts.queryParams.toString()}`;
}
await this.ctx.page.goto(url, {
waitUntil: 'networkidle',
});
if (opts?.timeRange) {
await this.timeRange.set(opts.timeRange);
}
}

async gotoPanelEditPage(panelId: string) {
const url = this.ctx.selectors.pages.Dashboard.url(this.dashboardUid ?? '');
await this.ctx.page.goto(`${url}?editPanel=${panelId}`, {
waitUntil: 'networkidle',
});
return new PanelEditPage(this.ctx, this.expect);
}

async addPanel(): Promise<PanelEditPage> {
if (gte(this.ctx.grafanaVersion, '10.0.0')) {
//TODO: add new selector and use it in grafana/ui
const title = gte(this.ctx.grafanaVersion, '10.1.0') ? 'Add button' : 'Add panel button';
await this.getByTestIdOrAriaLabel(this.ctx.selectors.components.PageToolbar.itemButton(title)).click();
await this.getByTestIdOrAriaLabel(
this.ctx.selectors.pages.AddDashboard.itemButton('Add new visualization menu item')
).click();
} else {
await this.getByTestIdOrAriaLabel(this.ctx.selectors.pages.AddDashboard.addNewPanel).click();
}

return new PanelEditPage(this.ctx, this.expect);
}

async deleteDashboard() {
await this.ctx.request.delete(`/api/datasources/uid/${this.dashboardUid}`);
}

async refreshDashboard() {
await this.ctx.page.getByTestId(this.ctx.selectors.components.RefreshPicker.runButtonV2).click();
}
}
21 changes: 21 additions & 0 deletions packages/plugin-e2e/src/models/DataSourcePicker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Expect } from '@playwright/test';
import { PluginTestCtx } from '../types';
import { GrafanaPage } from './GrafanaPage';

export class DataSourcePicker extends GrafanaPage {
constructor(ctx: PluginTestCtx, expect: Expect<any>) {
super(ctx, expect);
}

async set(name: string) {
await this.getByTestIdOrAriaLabel(this.ctx.selectors.components.DataSourcePicker.container)
.locator('input')
.fill(name);

// this is a hack to get the selection to work in 10.ish versions of Grafana.
// TODO: investigate if the select component can somehow be refactored so that its easier to test with playwright
await this.ctx.page.keyboard.press('ArrowDown');
await this.ctx.page.keyboard.press('ArrowUp');
await this.ctx.page.keyboard.press('Enter');
}
}
15 changes: 15 additions & 0 deletions packages/plugin-e2e/src/models/EmptyDashboardPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Expect } from '@playwright/test';
import { PluginTestCtx } from '../types';
import { DashboardPage } from './DashboardPage';

export class EmptyDashboardPage extends DashboardPage {
constructor(ctx: PluginTestCtx, expect: Expect<any>) {
super(ctx, expect);
}

async goto() {
await this.ctx.page.goto(this.ctx.selectors.pages.AddDashboard.url, {
waitUntil: 'networkidle',
});
}
}
18 changes: 16 additions & 2 deletions packages/plugin-e2e/src/models/GrafanaPage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Expect, Locator } from '@playwright/test';
import { Expect, Locator, Request } from '@playwright/test';
import { PluginTestCtx } from '../types';

/**
Expand Down Expand Up @@ -40,8 +40,22 @@ export abstract class GrafanaPage {
* @param status the HTTP status code to return. Defaults to 200
*/
async mockResourceResponse<T = any>(path: string, json: T, status = 200) {
await this.ctx.page.route(`${this.ctx.selectors.apis.DataSource.getResource}/${path}`, async (route) => {
await this.ctx.page.route(`${this.ctx.selectors.apis.DataSource.resource}/${path}`, async (route) => {
await route.fulfill({ json, status });
});
}

/**
* Waits for a data source query data request to be made.
*
* @param cb optional callback to filter the request. Use this to filter by request body or other request properties
*/
async waitForQueryDataRequest(cb?: (request: Request) => boolean | Promise<boolean>) {
return this.ctx.page.waitForRequest((request) => {
if (request.url().includes('api/ds/query') && request.method() === 'POST') {
return cb ? cb(request) : true;
}
return false;
});
}
}
Loading
Loading