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

First e2e tests for plugin initialization #4465

Merged
8 changes: 1 addition & 7 deletions grafana-plugin/e2e-tests/globalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,9 @@ import {
GRAFANA_VIEWER_USERNAME,
IS_CLOUD,
IS_OPEN_SOURCE,
OrgRole,
} from './utils/constants';

enum OrgRole {
None = 'None',
Viewer = 'Viewer',
Editor = 'Editor',
Admin = 'Admin',
}

type UserCreationSettings = {
adminAuthedRequest: APIRequestContext;
role: OrgRole;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { PLUGIN_ID } from 'utils/consts';

import { test, expect } from '../fixtures';
import { goToGrafanaPage } from '../utils/navigation';

test.describe('Plugin configuration', () => {
test('Admin user can see currently applied URL', async ({ adminRolePage: { page } }) => {
const urlInput = page.getByTestId('oncall-api-url-input');

await goToGrafanaPage(page, `/plugins/${PLUGIN_ID}`);
const currentlyAppliedURL = await urlInput.inputValue();

expect(currentlyAppliedURL).toBe('http://oncall-dev-engine:8080');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { test, expect } from '../fixtures';
import { OrgRole } from '../utils/constants';
import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation';
import { createGrafanaUser, loginAndWaitTillGrafanaIsLoaded } from '../utils/users';

test.describe('Plugin initialization', () => {
test('Plugin OnCall pages work for new viewer user right away', async ({ adminRolePage: { page }, browser }) => {
// Create new viewer user and login as new user
const USER_NAME = `viewer-${new Date().getTime()}`;
await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Viewer });

// Create new browser context to act as new user
const viewerUserContext = await browser.newContext();
const viewerUserPage = await viewerUserContext.newPage();

await loginAndWaitTillGrafanaIsLoaded({ page: viewerUserPage, username: USER_NAME });

// Start watching for HTTP responses
const networkResponseStatuses: number[] = [];
viewerUserPage.on('requestfinished', async (request) =>
networkResponseStatuses.push((await request.response()).status())
);

// Go to OnCall and assert that none of the requests failed
await goToOnCallPage(viewerUserPage, 'alert-groups');
await viewerUserPage.waitForLoadState('networkidle');
const numberOfFailedRequests = networkResponseStatuses.filter(
(status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3'))
).length;
expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled
});

test('Extension registered by OnCall plugin works for new editor user right away', async ({
adminRolePage: { page },
browser,
}) => {
// Create new editor user
const USER_NAME = `editor-${new Date().getTime()}`;
await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Editor });
await page.waitForLoadState('networkidle');

// Create new browser context to act as new user
const editorUserContext = await browser.newContext();
const editorUserPage = await editorUserContext.newPage();

await loginAndWaitTillGrafanaIsLoaded({ page: editorUserPage, username: USER_NAME });

// Start watching for HTTP responses
const networkResponseStatuses: number[] = [];
editorUserPage.on('requestfinished', async (request) =>
networkResponseStatuses.push((await request.response()).status())
);

// Go to profile -> IRM tab where OnCall plugin extension is registered and assert that none of the requests failed
await goToGrafanaPage(editorUserPage, '/profile?tab=irm');
await editorUserPage.waitForLoadState('networkidle');
const numberOfFailedRequests = networkResponseStatuses.filter(
(status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3'))
).length;
expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled

// ...as well as that user sees content of the extension
const extensionContentText = editorUserPage.getByText('Please connect Grafana Cloud OnCall to use the mobile app');
await extensionContentText.waitFor();
await expect(extensionContentText).toBeVisible();
});
});
27 changes: 17 additions & 10 deletions grafana-plugin/e2e-tests/users/usersActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@ import semver from 'semver';

import { test, expect } from '../fixtures';
import { goToOnCallPage } from '../utils/navigation';
import { viewUsers, accessProfileTabs } from '../utils/users';
import { verifyThatUserCanViewOtherUsers, accessProfileTabs } from '../utils/users';

test.describe('Users screen actions', () => {
test("Admin is allowed to edit other users' profile", async ({ adminRolePage: { page } }) => {
await goToOnCallPage(page, 'users');
await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(3);
const editableUsers = page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false });
await editableUsers.first().waitFor();
const editableUsersCount = await editableUsers.count();
expect(editableUsersCount).toBeGreaterThan(1);
});

test('Admin is allowed to view the list of users', async ({ adminRolePage: { page } }) => {
await viewUsers(page);
await verifyThatUserCanViewOtherUsers(page);
});

test('Viewer is not allowed to view the list of users', async ({ viewerRolePage: { page } }) => {
await viewUsers(page, false);
await verifyThatUserCanViewOtherUsers(page, false);
});

test('Viewer cannot access restricted tabs from View My Profile', async ({ viewerRolePage }) => {
const { page } = viewerRolePage;
const tabsToCheck = ['tab-phone-verification', 'tab-slack', 'tab-telegram'];

console.log(process.env.CURRENT_GRAFANA_VERSION);

// After 10.3 it's been moved to global user profile
if (semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0')) {
tabsToCheck.unshift('tab-mobile-app');
Expand All @@ -33,7 +34,7 @@ test.describe('Users screen actions', () => {
});

test('Editor is allowed to view the list of users', async ({ editorRolePage }) => {
await viewUsers(editorRolePage.page);
await verifyThatUserCanViewOtherUsers(editorRolePage.page);
});

test("Editor cannot view other users' data", async ({ editorRolePage }) => {
Expand All @@ -43,8 +44,10 @@ test.describe('Users screen actions', () => {
await page.getByTestId('users-email').and(page.getByText('editor')).waitFor();

await expect(page.getByTestId('users-email').and(page.getByText('editor'))).toHaveCount(1);
await expect(page.getByTestId('users-email').and(page.getByText('******'))).toHaveCount(2);
await expect(page.getByTestId('users-phone-number').and(page.getByText('******'))).toHaveCount(2);
const maskedEmailsCount = await page.getByTestId('users-email').and(page.getByText('******')).count();
expect(maskedEmailsCount).toBeGreaterThan(1);
const maskedPhoneNumbersCount = await page.getByTestId('users-phone-number').and(page.getByText('******')).count();
expect(maskedPhoneNumbersCount).toBeGreaterThan(1);
});

test('Editor can access tabs from View My Profile', async ({ editorRolePage }) => {
Expand All @@ -57,7 +60,11 @@ test.describe('Users screen actions', () => {
test("Editor is not allowed to edit other users' profile", async ({ editorRolePage: { page } }) => {
await goToOnCallPage(page, 'users');
await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(1);
await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: true })).toHaveCount(2);
const usersCountWithDisabledEdit = await page
.getByTestId('users-table')
.getByRole('button', { name: 'Edit', disabled: true })
.count();
expect(usersCountWithDisabledEdit).toBeGreaterThan(1);
});

test('Search updates the table view', async ({ adminRolePage }) => {
Expand Down
7 changes: 7 additions & 0 deletions grafana-plugin/e2e-tests/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'onc

export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true';
export const IS_CLOUD = !IS_OPEN_SOURCE;

export enum OrgRole {
None = 'None',
Viewer = 'Viewer',
Editor = 'Editor',
Admin = 'Admin',
}
5 changes: 3 additions & 2 deletions grafana-plugin/e2e-tests/utils/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type ClickButtonArgs = {
buttonText: string | RegExp;
// if provided, use this Locator as the root of our search for the button
startingLocator?: Locator;
exact?: boolean;
};

export const fillInInput = (page: Page, selector: string, value: string) => page.fill(selector, value);
Expand All @@ -34,9 +35,9 @@ export const fillInInputByPlaceholderValue = (page: Page, placeholderValue: stri

export const getInputByName = (page: Page, name: string): Locator => page.locator(`input[name="${name}"]`);

export const clickButton = async ({ page, buttonText, startingLocator }: ClickButtonArgs): Promise<void> => {
export const clickButton = async ({ page, buttonText, startingLocator, exact }: ClickButtonArgs): Promise<void> => {
const baseLocator = startingLocator || page;
await baseLocator.getByRole('button', { name: buttonText, disabled: false }).click();
await baseLocator.getByRole('button', { name: buttonText, disabled: false, exact }).click();
};

/**
Expand Down
47 changes: 44 additions & 3 deletions grafana-plugin/e2e-tests/utils/users.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Page, expect } from '@playwright/test';

import { goToOnCallPage } from './navigation';
import { OrgRole } from './constants';
import { clickButton } from './forms';
import { goToGrafanaPage, goToOnCallPage } from './navigation';

export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) {
await goToOnCallPage(page, 'users');
Expand Down Expand Up @@ -30,16 +32,55 @@ export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: b
}
}

export async function viewUsers(page: Page, isAllowedToView = true): Promise<void> {
export async function verifyThatUserCanViewOtherUsers(page: Page, isAllowedToView = true): Promise<void> {
await goToOnCallPage(page, 'users');

if (isAllowedToView) {
const usersTable = page.getByTestId('users-table');
await usersTable.getByRole('row').nth(1).waitFor();
await expect(usersTable.getByRole('row')).toHaveCount(4);
const usersCount = await page.getByTestId('users-table').getByRole('row').count();
expect(usersCount).toBeGreaterThan(1);
} else {
await expect(page.getByTestId('view-users-missing-permission-message')).toHaveText(
/You are missing the .* to be able to view OnCall users/
);
}
}

export const createGrafanaUser = async ({
page,
username,
role = OrgRole.Viewer,
}: {
page: Page;
username: string;
role?: OrgRole;
}): Promise<void> => {
await goToGrafanaPage(page, '/admin/users');
await page.getByRole('link', { name: 'New user' }).click();
await page.getByLabel('Name *').fill(username);
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password *').fill(username);
await clickButton({ page, buttonText: 'Create user' });

if (role !== OrgRole.Viewer) {
await clickButton({ page, buttonText: 'Change role' });
await page
.locator('div')
.filter({ hasText: /^Viewer$/ })
.nth(1)
.click();
await page.getByText(new RegExp(role)).click();
await clickButton({ page, buttonText: 'Save' });
}
};

export const loginAndWaitTillGrafanaIsLoaded = async ({ page, username }: { page: Page; username: string }) => {
await goToGrafanaPage(page, '/login');
await page.getByLabel('Email or username').fill(username);
await page.getByLabel(/Password/).fill(username);
await clickButton({ page, buttonText: 'Log in' });

await page.getByText('Welcome to Grafana').waitFor();
await page.waitForLoadState('networkidle');
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { locationUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafan
import { IconName, Menu } from '@grafana/ui';

import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge';
import { PLUGIN_ID } from 'utils/consts';
import { truncateTitle } from 'utils/string';

type Props = {
Expand Down Expand Up @@ -68,7 +69,7 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid
icon: 'fire',
category: 'Incident',
title: 'Declare incident',
pluginId: 'grafana-oncall-app',
pluginId: PLUGIN_ID,
} as Partial<PluginExtensionLink>,
])}
</Menu.Group>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
import { AppFeature } from 'state/features';
import { RootStore, rootStore as store } from 'state/rootStore';
import { UserActions } from 'utils/authorization/authorization';
import { useInitializePlugin } from 'utils/hooks';
import { isMobile, openErrorNotification, openNotification, openWarningNotification } from 'utils/utils';

import styles from './MobileAppConnection.module.scss';
Expand Down Expand Up @@ -364,10 +365,13 @@ function QRLoading() {

export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => {
const { userStore } = store;
const { isInitialized } = useInitializePlugin();

useEffect(() => {
loadData();
}, []);
if (isInitialized) {
loadData();
}
}, [isInitialized]);

const loadData = async () => {
if (!store.isBasicDataLoaded) {
Expand All @@ -379,7 +383,7 @@ export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => {
}
};

if (store.isBasicDataLoaded && userStore.currentUserPk) {
if (isInitialized && store.isBasicDataLoaded && userStore.currentUserPk) {
return <MobileAppConnection userPk={userStore.currentUserPk} />;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,54 @@
import React from 'react';

export const PluginConfigPage = () => <>plugin config page</>;
import { PluginConfigPageProps, PluginMeta } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Field, HorizontalGroup, Input } from '@grafana/ui';
import { observer } from 'mobx-react-lite';
import { Controller, useForm } from 'react-hook-form';
import { OnCallPluginMetaJSONData } from 'types';

import { Button } from 'components/Button/Button';
import { getOnCallApiUrl } from 'utils/consts';
import { validateURL } from 'utils/string';

type PluginConfigFormValues = {
onCallApiUrl: string;
};

export const PluginConfigPage = observer((props: PluginConfigPageProps<PluginMeta<OnCallPluginMetaJSONData>>) => {
const { handleSubmit, control, formState } = useForm<PluginConfigFormValues>({
mode: 'onChange',
defaultValues: { onCallApiUrl: getOnCallApiUrl(props.plugin.meta) },
});

const onSubmit = (values: PluginConfigFormValues) => {
// eslint-disable-next-line no-console
console.log(values);
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name={'onCallApiUrl'}
control={control}
rules={{ required: 'OnCall API URL is required', validate: validateURL }}
render={({ field }) => (
<Field
key={'Name'}
label={'OnCall API URL'}
invalid={Boolean(formState.errors.onCallApiUrl)}
error={formState.errors.onCallApiUrl?.message}
>
<Input {...field} placeholder={'OnCall API URL'} data-testid="oncall-api-url-input" />
</Field>
)}
/>
<HorizontalGroup>
<Button type="submit" disabled={!formState.isValid}>
Test & Save connection
</Button>
{config.featureToggles.externalServiceAccounts && <Button variant="secondary">Recreate service account</Button>}
</HorizontalGroup>
</form>
);
});
Loading
Loading