stabilize e2e tests (#3349)

# What this PR does
Stabilize e2e tests by:
- improve usage of locators
- fix unreliable selectors
- prevent parallelism within the same test file

Additionally:
- configure eslint for e2e tests and fix existing errors/warnings
- bump Playwright version to latest stable

## Which issue(s) this PR fixes
https://github.com/grafana/oncall/issues/3217

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Dominik Broj 2023-11-17 11:07:12 +01:00 committed by GitHub
parent 719765a72d
commit 45ae04088f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 386 additions and 355 deletions

View file

@ -0,0 +1,6 @@
{
"rules": {
"rulesdir/no-relative-import-paths": "off",
"no-console": "off"
}
}

View file

@ -13,8 +13,8 @@ test('we can directly page a user', async ({ adminRolePage }) => {
const { page } = adminRolePage;
await goToOnCallPage(page, 'alert-groups');
await page.waitForTimeout(1000);
await clickButton({ page, buttonText: 'Escalation' });
await fillInInput(page, 'textarea[name="message"]', message);
await clickButton({ page, buttonText: 'Invite' });
@ -23,8 +23,14 @@ test('we can directly page a user', async ({ adminRolePage }) => {
await addRespondersPopup.getByText('Users').click();
await addRespondersPopup.getByText(adminRolePage.userName).click();
await clickButton({ page, buttonText: 'Create' });
// If user is not on call, confirm invitation
await page.waitForTimeout(1000);
const isConfirmationModalShown = await page.getByText('Confirm Participant Invitation').isVisible();
if (isConfirmationModalShown) {
await page.getByTestId('confirm-non-oncall').click();
}
await clickButton({ page, buttonText: 'Create' });
// Check we are redirected to the alert group page
await page.waitForURL('**/alert-groups/I*'); // Alert group IDs always start with "I"
await expect(page.getByTestId('incident-message')).toContainText(message);

View file

@ -1,6 +1,6 @@
import {expect, test} from "../fixtures";
import {generateRandomValue} from "../utils/forms";
import {createEscalationChain, EscalationStep, selectEscalationStepValue} from "../utils/escalationChain";
import {generateRandomValue} from "../utils/forms";
test('escalation policy does not go back to "Default" after adding users to notify', async ({ adminRolePage }) => {
const { page, userName } = adminRolePage;

View file

@ -1,6 +1,6 @@
import { test, expect, Page } from '../fixtures';
import { generateRandomValue } from '../utils/forms';
import { createEscalationChain } from '../utils/escalationChain';
import { generateRandomValue } from '../utils/forms';
const assertEscalationChainSearchWorks = async (
page: Page,

View file

@ -1,10 +1,14 @@
import * as fs from 'fs';
import * as path from 'path';
import { test as base, Browser, Page, TestInfo } from '@playwright/test';
import { GRAFANA_ADMIN_USERNAME, GRAFANA_EDITOR_USERNAME, GRAFANA_VIEWER_USERNAME } from './utils/constants';
import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config';
import { GRAFANA_ADMIN_USERNAME, GRAFANA_EDITOR_USERNAME, GRAFANA_VIEWER_USERNAME } from './utils/constants';
import * as fs from 'fs';
import * as path from 'path';
export class BaseRolePage {
page: Page;
userName: string;

View file

@ -1,5 +1,8 @@
import { OrgRole } from '@grafana/data';
import { test as setup, chromium, expect, Page, BrowserContext, FullConfig, APIRequestContext } from '@playwright/test';
import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config';
import GrafanaAPIClient from './utils/clients/grafana';
import {
GRAFANA_ADMIN_PASSWORD,
@ -14,8 +17,6 @@ import {
} from './utils/constants';
import { clickButton, getInputByName } from './utils/forms';
import { goToGrafanaPage } from './utils/navigation';
import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config';
import { OrgRole } from '@grafana/data';
const grafanaApiClient = new GrafanaAPIClient(GRAFANA_ADMIN_USERNAME, GRAFANA_ADMIN_PASSWORD);

View file

@ -1,5 +1,4 @@
import { test, Page, expect } from '../fixtures';
import { generateRandomValue, selectDropdownValue } from '../utils/forms';
import { createIntegration } from '../utils/integrations';
@ -7,10 +6,7 @@ const HEARTBEAT_SETTINGS_FORM_TEST_ID = 'heartbeat-settings-form';
test.describe("updating an integration's heartbeat interval works", async () => {
const _openHeartbeatSettingsForm = async (page: Page) => {
const integrationSettingsPopupElement = page.getByTestId('integration-settings-context-menu');
await integrationSettingsPopupElement.waitFor({ state: 'visible' });
await integrationSettingsPopupElement.click();
await page.getByTestId('integration-settings-context-menu-wrapper').getByRole('img').click();
await page.getByTestId('integration-heartbeat-settings').click();
};
@ -60,6 +56,6 @@ test.describe("updating an integration's heartbeat interval works", async () =>
*/
await page.request.get(endpoint);
await page.reload({ waitUntil: 'networkidle' });
await page.getByTestId('heartbeat-badge').waitFor({ state: 'visible' });
await page.getByTestId('heartbeat-badge').waitFor();
});
});

View file

@ -1,29 +1,31 @@
import { test, expect } from '../fixtures';
import { test } from '../fixtures';
import { generateRandomValue } from '../utils/forms';
import { createIntegration } from '../utils/integrations';
import { createIntegration, searchIntegrationAndAssertItsPresence } from '../utils/integrations';
test('Integrations table shows data in Connections and Direct Paging tabs', async ({ adminRolePage: { page } }) => {
// // Create 2 integrations that are not Direct Paging
const ID = generateRandomValue();
const WEBHOOK_INTEGRATION_NAME = `Webhook-${ID}`;
const ALERTMANAGER_INTEGRATION_NAME = `Alertmanager-${ID}`;
const DIRECT_PAGING_INTEGRATION_NAME = `Direct paging`;
const DIRECT_PAGING_INTEGRATION_NAME = `Direct paging integration name`;
// Create 2 integrations that are not Direct Paging
await createIntegration({ page, integrationSearchText: 'Webhook', integrationName: WEBHOOK_INTEGRATION_NAME });
await page.waitForTimeout(1000);
await page.getByRole('tab', { name: 'Tab Integrations' }).click();
await createIntegration({
page,
integrationSearchText: 'Alertmanager',
shouldGoToIntegrationsPage: false,
integrationName: ALERTMANAGER_INTEGRATION_NAME,
});
await page.waitForTimeout(1000);
await page.getByRole('tab', { name: 'Tab Integrations' }).click();
// Create 1 Direct Paging integration if it doesn't exist
const integrationsTable = page.getByTestId('integrations-table');
await page.getByRole('tab', { name: 'Tab Direct Paging' }).click();
const isDirectPagingAlreadyCreated = await page.getByText('Direct paging').isVisible();
const integrationsTable = page.getByTestId('integrations-table');
await page.waitForTimeout(2000);
const isDirectPagingAlreadyCreated = (await integrationsTable.getByText('Direct paging').count()) >= 1;
if (!isDirectPagingAlreadyCreated) {
await createIntegration({
page,
@ -31,17 +33,41 @@ test('Integrations table shows data in Connections and Direct Paging tabs', asyn
shouldGoToIntegrationsPage: false,
integrationName: DIRECT_PAGING_INTEGRATION_NAME,
});
await page.waitForTimeout(1000);
}
await page.getByRole('tab', { name: 'Tab Integrations' }).click();
// By default Connections tab is opened and newly created integrations are visible except Direct Paging one
await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).toBeVisible();
await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).toBeVisible();
await expect(integrationsTable).not.toContainText(DIRECT_PAGING_INTEGRATION_NAME);
await searchIntegrationAndAssertItsPresence({ page, integrationsTable, integrationName: WEBHOOK_INTEGRATION_NAME });
await searchIntegrationAndAssertItsPresence({
page,
integrationsTable,
integrationName: ALERTMANAGER_INTEGRATION_NAME,
});
await searchIntegrationAndAssertItsPresence({
page,
integrationsTable,
integrationName: DIRECT_PAGING_INTEGRATION_NAME,
visibleExpected: false,
});
// Then after switching to Direct Paging tab only Direct Paging integration is visible
await page.getByRole('tab', { name: 'Tab Direct Paging' }).click();
await expect(integrationsTable.getByText(WEBHOOK_INTEGRATION_NAME)).not.toBeVisible();
await expect(integrationsTable.getByText(ALERTMANAGER_INTEGRATION_NAME)).not.toBeVisible();
await expect(integrationsTable).toContainText(DIRECT_PAGING_INTEGRATION_NAME);
await searchIntegrationAndAssertItsPresence({
page,
integrationsTable,
integrationName: WEBHOOK_INTEGRATION_NAME,
visibleExpected: false,
});
await searchIntegrationAndAssertItsPresence({
page,
integrationsTable,
integrationName: ALERTMANAGER_INTEGRATION_NAME,
visibleExpected: false,
});
await searchIntegrationAndAssertItsPresence({
page,
integrationsTable,
integrationName: 'Direct paging',
});
});

View file

@ -13,8 +13,6 @@ import { goToOnCallPage } from '../utils/navigation';
type MaintenanceModeType = 'Debug' | 'Maintenance';
test.describe('maintenance mode works', () => {
test.slow(); // this test is doing a good amount of work, give it time
const MAINTENANCE_DURATION = '1 hour';
const REMAINING_TIME_TEXT = '59m left';
const REMAINING_TIME_TOOLTIP_TEST_ID = 'maintenance-mode-remaining-time-tooltip';
@ -22,27 +20,27 @@ test.describe('maintenance mode works', () => {
const createRoutedText = (escalationChainName: string): string =>
`alert group assigned to route "default" with escalation chain "${escalationChainName}"`;
const _openIntegrationSettingsPopup = async (page: Page): Promise<Locator> => {
const integrationSettingsPopupElement = page.getByTestId('integration-settings-context-menu');
await integrationSettingsPopupElement.waitFor({ state: 'visible' });
const _openIntegrationSettingsPopup = async (page: Page, shouldDoubleClickSettingsIcon = false): Promise<void> => {
await page.waitForTimeout(2000);
const integrationSettingsPopupElement = page
.getByTestId('integration-settings-context-menu-wrapper')
.getByRole('img');
await integrationSettingsPopupElement.click();
return integrationSettingsPopupElement;
/**
* sometimes we need to click twice (e.g. adding the escalation chain route
* doesn't unfocus out of the select element after selecting an option)
*/
if (shouldDoubleClickSettingsIcon) {
await integrationSettingsPopupElement.click();
}
};
const getRemainingTimeTooltip = (page: Page): Locator => page.getByTestId(REMAINING_TIME_TOOLTIP_TEST_ID);
const enableMaintenanceMode = async (page: Page, mode: MaintenanceModeType): Promise<void> => {
const integrationSettingsPopupElement = await _openIntegrationSettingsPopup(page);
/**
* we need to click twice here, because adding the escalation chain route
* doesn't unfocus out of the select element after selecting an option
*/
await integrationSettingsPopupElement.click();
await _openIntegrationSettingsPopup(page, true);
// open the maintenance mode settings drawer + fill in the maintenance details
const startMaintenanceModeButton = page.getByTestId('integration-start-maintenance');
await startMaintenanceModeButton.waitFor({ state: 'visible' });
await startMaintenanceModeButton.click();
await page.getByTestId('integration-start-maintenance').click();
// fill in the form
const maintenanceModeDrawer = page.getByTestId('maintenance-mode-drawer');
@ -77,12 +75,10 @@ test.describe('maintenance mode works', () => {
await goToOnCallPage(page, 'integrations');
await filterIntegrationsTableAndGoToDetailPage(page, integrationName);
await _openIntegrationSettingsPopup(page);
await _openIntegrationSettingsPopup(page, true);
// click the stop maintenance button
const stopMaintenanceModeButton = page.getByTestId('integration-stop-maintenance');
await stopMaintenanceModeButton.waitFor({ state: 'visible' });
await stopMaintenanceModeButton.click();
await page.getByTestId('integration-stop-maintenance').click();
// in the modal popup, confirm that we want to stop it
await clickButton({
@ -114,6 +110,8 @@ test.describe('maintenance mode works', () => {
};
test('debug mode', async ({ adminRolePage: { page, userName } }) => {
test.slow();
const { escalationChainName, integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
page,
userName,
@ -130,6 +128,7 @@ test.describe('maintenance mode works', () => {
});
test('"maintenance" mode', async ({ adminRolePage: { page, userName } }) => {
test.slow();
const { integrationName } = await createIntegrationAndEscalationChainAndEnableMaintenanceMode(
page,
userName,

View file

@ -1,8 +1,10 @@
import { test, expect } from '../fixtures';
import { openCreateIntegrationModal } from '../utils/integrations';
import { goToOnCallPage } from '../utils/navigation';
test('integrations have unique names', async ({ adminRolePage }) => {
const { page } = adminRolePage;
await goToOnCallPage(page, 'integrations');
await openCreateIntegrationModal(page);
const integrationNames = await page.getByTestId('integration-display-name').allInnerTexts();

View file

@ -1,7 +1,8 @@
import dayjs from 'dayjs';
import { test, expect } from '../fixtures';
import { clickButton, generateRandomValue } from '../utils/forms';
import { createOnCallSchedule, getOverrideFormDateInputs } from '../utils/schedule';
import dayjs from 'dayjs';
test('default dates in override creation modal are correct', async ({ adminRolePage }) => {
const { page, userName } = adminRolePage;

View file

@ -1,136 +1,68 @@
import { test, expect, Page } from '../fixtures';
import { test, expect } from '../fixtures';
import { goToOnCallPage } from '../utils/navigation';
import { viewUsers, accessProfileTabs } from '../utils/users';
test.describe('Users screen actions', () => {
test("Admin is allowed to edit other users' profile", async ({ adminRolePage }) => {
await _testButtons(adminRolePage.page, 'button.edit-other-profile-button[disabled]');
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);
});
test('Admin is allowed to view the list of users', async ({ adminRolePage }) => {
await _viewUsers(adminRolePage.page);
test('Admin is allowed to view the list of users', async ({ adminRolePage: { page } }) => {
await viewUsers(page);
});
test('Viewer is not allowed to view the list of users', async ({ viewerRolePage }) => {
await _viewUsers(viewerRolePage.page, false);
test('Viewer is not allowed to view the list of users', async ({ viewerRolePage: { page } }) => {
await viewUsers(page, false);
});
test('Viewer cannot access restricted tabs from View My Profile', async ({ viewerRolePage }) => {
const { page } = viewerRolePage;
await _accessProfileTabs(page, ['tab-mobile-app', 'tab-phone-verification', 'tab-slack', 'tab-telegram'], false);
await accessProfileTabs(page, ['tab-mobile-app', 'tab-phone-verification', 'tab-slack', 'tab-telegram'], false);
});
test('Editor is allowed to view the list of users', async ({ editorRolePage }) => {
await _viewUsers(editorRolePage.page);
await viewUsers(editorRolePage.page);
});
test("Editor cannot view other users' data", async ({ editorRolePage }) => {
const { page } = editorRolePage;
await goToOnCallPage(page, 'users');
await page.waitForSelector('.current-user');
await page.getByTestId('users-email').and(page.getByText('editor')).waitFor();
// check if these fields are Masked or Not (******)
const fieldIds = ['users-email', 'users-phone-number'];
for (let i = 0; i < fieldIds.length - 1; ++i) {
const currentUsername = page.locator(`.current-user [data-testid="${fieldIds[i]}"]`);
expect((await currentUsername.all()).length).toBe(1); // match for current user
(await currentUsername.all()).forEach((val) => expect(val).not.toHaveText('******'));
const otherUsername = page.locator(`.other-user [data-testid="${fieldIds[i]}"]`);
expect((await otherUsername.all()).length).toBeGreaterThan(1); // match for other users (>= 1)
(await otherUsername.all()).forEach((val) => expect(val).toHaveText('******'));
}
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);
});
test('Editor can access tabs from View My Profile', async ({ editorRolePage }) => {
const { page } = editorRolePage;
// the other tabs depend on Cloud, skip for now
await _accessProfileTabs(page, ['tab-slack', 'tab-telegram'], true);
await accessProfileTabs(page, ['tab-slack', 'tab-telegram'], true);
});
test("Editor is not allowed to edit other users' profile", async ({ editorRolePage }) => {
await _testButtons(editorRolePage.page, 'button.edit-other-profile-button:not([disabled])');
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);
});
test('Search updates the table view', async ({ adminRolePage }) => {
const { page } = adminRolePage;
await goToOnCallPage(page, 'users');
await page.waitForTimeout(1000);
await page.waitForTimeout(2000);
const searchInput = page.locator(`[data-testid="search-users"]`);
await searchInput.fill('oncall');
await page.waitForTimeout(5000);
await page.waitForTimeout(2000);
const result = page.locator(`[data-testid="users-username"]`);
expect(await result.count()).toBe(1);
});
/*
* Helper methods
*/
async function _testButtons(page: Page, selector: string) {
await goToOnCallPage(page, 'users');
const usersTableElement = page.getByTestId('users-table');
await usersTableElement.waitFor({ state: 'visible' });
const buttonsList = await page.locator(selector);
expect(buttonsList).toHaveCount(0);
}
async function _accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) {
await goToOnCallPage(page, 'users');
await page.getByTestId('users-view-my-profile').click();
// the next queries could or could not resolve
// therefore we wait a generic 1000ms duration and assert based on visibility
await page.waitForTimeout(1000);
for (let i = 0; i < tabs.length - 1; ++i) {
const tab = page.getByTestId(tabs[i]);
if (await tab.isVisible()) {
await tab.click();
const query = page.getByText(
'You do not have permission to perform this action. Ask an admin to upgrade your permissions.'
);
if (hasAccess) {
await expect(query).toBeHidden();
} else {
await expect(query).toBeVisible();
}
}
}
}
async function _viewUsers(page: Page, isAllowedToView = true): Promise<void> {
await goToOnCallPage(page, 'users');
if (isAllowedToView) {
const usersTableElement = page.getByTestId('users-table');
await usersTableElement.waitFor({ state: 'visible' });
const userRowsContext = await usersTableElement.locator('tbody > tr').allTextContents();
expect(userRowsContext.length).toBeGreaterThan(0);
} else {
const missingPermissionsMessageElement = page.getByTestId('view-users-missing-permission-message');
await missingPermissionsMessageElement.waitFor({ state: 'visible' });
const missingPermissionMessage = await missingPermissionsMessageElement.textContent();
expect(missingPermissionMessage).toMatch(/You are missing the .* to be able to view OnCall users/);
}
}
});

View file

@ -1,4 +1,5 @@
import { Locator, Page, expect } from '@playwright/test';
import { selectDropdownValue, selectValuePickerValue } from './forms';
import { goToOnCallPage } from './navigation';

View file

@ -95,7 +95,7 @@ export default class GrafanaAPIClient {
// user was just created
const respJson: CreateUserResponse = await res.json();
userId = respJson.id;
} else if (responseCode == 412) {
} else if (responseCode === 412) {
// user already exists, go fetch their user id
userId = await this.getUserIdByUsername(request, userName);
} else {

View file

@ -1,4 +1,5 @@
import type { Locator, Page } from '@playwright/test';
import { randomUUID } from 'crypto';
type SelectorType = 'gSelect' | 'grafanaSelect';
@ -22,9 +23,6 @@ type SelectDropdownValueArgs = {
type ClickButtonArgs = {
page: Page;
buttonText: string;
// if provided, search for the button by data-testid
dataTestId?: string;
// if provided, use this Locator as the root of our search for the button
startingLocator?: Locator;
};
@ -36,17 +34,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,
dataTestId,
}: ClickButtonArgs): Promise<void> => {
const baseLocator = dataTestId ? `button[data-testid="${dataTestId}"]` : 'button';
const button = (startingLocator || page).locator(`${baseLocator}:not([disabled]) >> text=${buttonText}`);
await button.waitFor({ state: 'visible' });
await button.click();
export const clickButton = async ({ page, buttonText, startingLocator }: ClickButtonArgs): Promise<void> => {
const baseLocator = startingLocator || page;
await baseLocator.getByRole('button', { name: buttonText, disabled: false }).click();
};
/**
@ -94,7 +84,7 @@ export const selectDropdownValue = async (args: SelectDropdownValueArgs): Promis
const { page, value, pressEnterInsteadOfSelectingOption } = args;
const selectElement = await openSelect(args);
await selectElement.type(value);
await selectElement.pressSequentially(value);
if (pressEnterInsteadOfSelectingOption) {
await page.keyboard.press('Enter');

View file

@ -1,4 +1,5 @@
import { Page } from '@playwright/test';
import { Locator, Page, expect } from '@playwright/test';
import { clickButton, generateRandomValue, selectDropdownValue } from './forms';
import { goToOnCallPage } from './navigation';
@ -60,8 +61,8 @@ export const assignEscalationChainToIntegration = async (page: Page, escalationC
};
export const sendDemoAlert = async (page: Page): Promise<void> => {
await clickButton({ page, buttonText: 'Send demo alert', dataTestId: 'send-demo-alert' });
await clickButton({ page, buttonText: 'Send Alert', dataTestId: 'submit-send-alert' });
await clickButton({ page, buttonText: 'Send demo alert' });
await clickButton({ page, buttonText: 'Send Alert' });
await page.getByTestId('demo-alert-sent-notification').waitFor({ state: 'visible' });
};
@ -85,9 +86,32 @@ export const filterIntegrationsTableAndGoToDetailPage = async (page: Page, integ
pressEnterInsteadOfSelectingOption: true,
});
await (
await page.waitForSelector(
`div[data-testid="integrations-table"] table > tbody > tr > td:first-child a >> text=${integrationName}`
)
).click();
await page.getByTestId('integrations-table').getByText(`${integrationName}`).click();
};
export const searchIntegrationAndAssertItsPresence = async ({
page,
integrationName,
integrationsTable,
visibleExpected = true,
}: {
page: Page;
integrationsTable: Locator;
integrationName: string;
visibleExpected?: boolean;
}) => {
await page
.locator('div')
.filter({ hasText: /^Search or filter results\.\.\.$/ })
.nth(1)
.click();
await page.keyboard.insertText(integrationName);
await page.keyboard.press('Enter');
await page.waitForTimeout(2000);
const nbOfResults = await integrationsTable.getByText(integrationName).count();
if (visibleExpected) {
expect(nbOfResults).toBeGreaterThanOrEqual(1);
} else {
expect(nbOfResults).toBe(0);
}
};

View file

@ -1,4 +1,5 @@
import type { Page, Response } from '@playwright/test';
import { BASE_URL } from './constants';
type GrafanaPage = '/plugins/grafana-oncall-app';

View file

@ -1,7 +1,8 @@
import { Page } from '@playwright/test';
import dayjs from 'dayjs';
import { clickButton, fillInInput, selectDropdownValue } from './forms';
import { goToOnCallPage } from './navigation';
import dayjs from 'dayjs';
export const createOnCallSchedule = async (page: Page, scheduleName: string, userName: string): Promise<void> => {
// go to the schedules page

View file

@ -0,0 +1,45 @@
import { Page, expect } from '@playwright/test';
import { goToOnCallPage } from './navigation';
export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) {
await goToOnCallPage(page, 'users');
await page.getByTestId('users-view-my-profile').click();
// the next queries could or could not resolve
// therefore we wait a generic 1000ms duration and assert based on visibility
await page.waitForTimeout(1000);
for (let i = 0; i < tabs.length - 1; ++i) {
const tab = page.getByTestId(tabs[i]);
if (await tab.isVisible()) {
await tab.click();
const query = page.getByText(
'You do not have permission to perform this action. Ask an admin to upgrade your permissions.'
);
if (hasAccess) {
await expect(query).toBeHidden();
} else {
await expect(query).toBeVisible();
}
}
}
}
export async function viewUsers(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);
} else {
await expect(page.getByTestId('view-users-missing-permission-message')).toHaveText(
/You are missing the .* to be able to view OnCall users/
);
}
}

View file

@ -3,8 +3,8 @@
"version": "dev-oss",
"description": "Grafana OnCall Plugin",
"scripts": {
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src",
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 --quiet ./src",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src ./e2e-tests",
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 --quiet ./src ./e2e-tests",
"stylelint": "stylelint ./src/**/*.{css,scss,module.css,module.scss}",
"stylelint:fix": "stylelint --fix ./src/**/*.{css,scss,module.css,module.scss}",
"build": "grafana-toolkit plugin:build",
@ -64,7 +64,7 @@
"@grafana/eslint-config": "^5.1.0",
"@grafana/toolkit": "^9.5.2",
"@jest/globals": "^27.5.1",
"@playwright/test": "^1.35.1",
"@playwright/test": "^1.39.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "12",
"@testing-library/user-event": "^14.4.3",

View file

@ -1,8 +1,6 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
import type { PlaywrightTestConfig } from '@playwright/test';
import { devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
@ -13,10 +11,12 @@ export const VIEWER_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/v
export const EDITOR_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/editor.json');
export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/admin.json');
const IS_CI = !!process.env.CI;
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
export default defineConfig({
testDir: './e2e-tests',
/* Maximum time all the tests can run for. */
@ -32,16 +32,16 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
},
/* Run tests in files in parallel */
fullyParallel: true,
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
forbidOnly: IS_CI,
/**
* Retry on CI only
*
* NOTE: until we fix this issue (https://github.com/grafana/oncall/issues/1692) which occasionally leads
* to flaky tests.. let's just retry failed tests. If the same test fails 3 times, you know something must be up
* to flaky tests.. let's allow 1 retry per test
*/
retries: !!process.env.CI ? 3 : 0,
retries: IS_CI ? 1 : 0,
workers: 2,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
@ -52,10 +52,9 @@ const config: PlaywrightTestConfig = {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on',
video: 'on',
headless: !!process.env.CI,
headless: IS_CI,
},
/* Configure projects for major browsers */
@ -66,38 +65,28 @@ const config: PlaywrightTestConfig = {
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
use: devices['Desktop Chrome'],
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
use: devices['Desktop Firefox'],
dependencies: ['setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
use: devices['Desktop Safari'],
dependencies: ['setup'],
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// use: devices['Pixel 5'],
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// use: devices['iPhone 12'],
// },
/* Test against branded browsers. */
@ -123,6 +112,4 @@ const config: PlaywrightTestConfig = {
// command: 'npm run start',
// port: 3000,
// },
};
export default config;
});

View file

@ -214,7 +214,7 @@ const AddResponders = observer(
<Button variant="secondary" onClick={closeUserConfirmationModal}>
Cancel
</Button>
<Button variant="primary" onClick={confirmCurrentlyConsideredUser}>
<Button variant="primary" onClick={confirmCurrentlyConsideredUser} data-testid="confirm-non-oncall">
Confirm
</Button>
</HorizontalGroup>

View file

@ -834,164 +834,167 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
</Button>
</WithPermissionControlTooltip>
<WithContextMenu
data-testid="integration-settings-context-menu"
renderMenuItems={() => (
<div className={cx('integration__actionsList')} id="integration-menu-options">
<div className={cx('integration__actionItem')} onClick={() => openIntegrationSettings()}>
<Text type="primary">Integration Settings</Text>
</div>
{store.hasFeature(AppFeature.Labels) && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')} onClick={() => openLabelsForm()}>
<Text type="primary">Alert group labels</Text>
</div>
</WithPermissionControlTooltip>
)}
{showHeartbeatSettings() && (
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
<div
className={cx('integration__actionItem')}
onClick={() => setIsHeartbeatFormOpen(true)}
data-testid="integration-heartbeat-settings"
>
Heartbeat Settings
</div>
</WithPermissionControlTooltip>
)}
{!alertReceiveChannel.maintenance_till && (
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div
className={cx('integration__actionItem')}
onClick={openStartMaintenance}
data-testid="integration-start-maintenance"
>
<Text type="primary">Start Maintenance</Text>
</div>
</WithPermissionControlTooltip>
)}
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div className={cx('integration__actionItem')} onClick={changeIsTemplateSettingsOpen}>
<Text type="primary">Edit Templates</Text>
<div data-testid="integration-settings-context-menu-wrapper">
<WithContextMenu
renderMenuItems={() => (
<div className={cx('integration__actionsList')} id="integration-menu-options">
<div className={cx('integration__actionItem')} onClick={() => openIntegrationSettings()}>
<Text type="primary">Integration Settings</Text>
</div>
</WithPermissionControlTooltip>
{alertReceiveChannel.maintenance_till && (
{store.hasFeature(AppFeature.Labels) && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')} onClick={() => openLabelsForm()}>
<Text type="primary">Alert group labels</Text>
</div>
</WithPermissionControlTooltip>
)}
{showHeartbeatSettings() && (
<WithPermissionControlTooltip key="ok" userAction={UserActions.IntegrationsWrite}>
<div
className={cx('integration__actionItem')}
onClick={() => setIsHeartbeatFormOpen(true)}
data-testid="integration-heartbeat-settings"
>
Heartbeat Settings
</div>
</WithPermissionControlTooltip>
)}
{!alertReceiveChannel.maintenance_till && (
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div
className={cx('integration__actionItem')}
onClick={openStartMaintenance}
data-testid="integration-start-maintenance"
>
<Text type="primary">Start Maintenance</Text>
</div>
</WithPermissionControlTooltip>
)}
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div
className={cx('integration__actionItem')}
onClick={() => {
setConfirmModal({
isOpen: true,
confirmText: 'Stop',
dismissText: 'Cancel',
onConfirm: onStopMaintenance,
title: 'Stop Maintenance',
body: (
<Text type="primary">
Are you sure you want to stop the maintenance for{' '}
<Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
),
});
}}
data-testid="integration-stop-maintenance"
>
<Text type="primary">Stop Maintenance</Text>
<div className={cx('integration__actionItem')} onClick={changeIsTemplateSettingsOpen}>
<Text type="primary">Edit Templates</Text>
</div>
</WithPermissionControlTooltip>
)}
{isLegacyIntegration && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div
className={cx('integration__actionItem')}
onClick={() =>
setConfirmModal({
isOpen: true,
title: 'Migrate Integration?',
body: (
<VerticalGroup spacing="lg">
{alertReceiveChannel.maintenance_till && (
<WithPermissionControlTooltip userAction={UserActions.MaintenanceWrite}>
<div
className={cx('integration__actionItem')}
onClick={() => {
setConfirmModal({
isOpen: true,
confirmText: 'Stop',
dismissText: 'Cancel',
onConfirm: onStopMaintenance,
title: 'Stop Maintenance',
body: (
<Text type="primary">
Are you sure you want to migrate <Emoji text={alertReceiveChannel.verbal_name} /> ?
Are you sure you want to stop the maintenance for{' '}
<Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
),
});
}}
data-testid="integration-stop-maintenance"
>
<Text type="primary">Stop Maintenance</Text>
</div>
</WithPermissionControlTooltip>
)}
<VerticalGroup spacing="xs">
<Text type="secondary">- Integration internal behaviour will be changed</Text>
<Text type="secondary">
- Integration URL will stay the same, so no need to change {getMigrationDisplayName()}{' '}
configuration
{isLegacyIntegration && (
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div
className={cx('integration__actionItem')}
onClick={() =>
setConfirmModal({
isOpen: true,
title: 'Migrate Integration?',
body: (
<VerticalGroup spacing="lg">
<Text type="primary">
Are you sure you want to migrate <Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
<Text type="secondary">
- Integration templates will be reset to suit the new payload
</Text>
<Text type="secondary">- It is needed to adjust routes manually to the new payload</Text>
<VerticalGroup spacing="xs">
<Text type="secondary">- Integration internal behaviour will be changed</Text>
<Text type="secondary">
- Integration URL will stay the same, so no need to change {getMigrationDisplayName()}{' '}
configuration
</Text>
<Text type="secondary">
- Integration templates will be reset to suit the new payload
</Text>
<Text type="secondary">
- It is needed to adjust routes manually to the new payload
</Text>
</VerticalGroup>
</VerticalGroup>
</VerticalGroup>
),
onConfirm: onIntegrationMigrate,
dismissText: 'Cancel',
confirmText: 'Migrate',
})
}
>
Migrate
),
onConfirm: onIntegrationMigrate,
dismissText: 'Cancel',
confirmText: 'Migrate',
})
}
>
Migrate
</div>
</WithPermissionControlTooltip>
)}
<CopyToClipboard
text={alertReceiveChannel.id}
onCopy={() => openNotification('Integration ID is copied')}
>
<div className={cx('integration__actionItem')}>
<HorizontalGroup spacing={'xs'}>
<Icon name="copy" />
<Text type="primary">UID: {alertReceiveChannel.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
<div className={cx('thin-line-break')} />
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')}>
<div
onClick={() => {
setConfirmModal({
isOpen: true,
title: 'Delete Integration?',
body: (
<Text type="primary">
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
),
onConfirm: deleteIntegration,
dismissText: 'Cancel',
confirmText: 'Delete',
});
}}
className="u-width-100"
>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
<Icon name="trash-alt" />
<span>Delete Integration</span>
</HorizontalGroup>
</Text>
</div>
</div>
</WithPermissionControlTooltip>
)}
<CopyToClipboard
text={alertReceiveChannel.id}
onCopy={() => openNotification('Integration ID is copied')}
>
<div className={cx('integration__actionItem')}>
<HorizontalGroup spacing={'xs'}>
<Icon name="copy" />
<Text type="primary">UID: {alertReceiveChannel.id}</Text>
</HorizontalGroup>
</div>
</CopyToClipboard>
<div className={cx('thin-line-break')} />
<WithPermissionControlTooltip userAction={UserActions.IntegrationsWrite}>
<div className={cx('integration__actionItem')}>
<div
onClick={() => {
setConfirmModal({
isOpen: true,
title: 'Delete Integration?',
body: (
<Text type="primary">
Are you sure you want to delete <Emoji text={alertReceiveChannel.verbal_name} /> ?
</Text>
),
onConfirm: deleteIntegration,
dismissText: 'Cancel',
confirmText: 'Delete',
});
}}
className="u-width-100"
>
<Text type="danger">
<HorizontalGroup spacing={'xs'}>
<Icon name="trash-alt" />
<span>Delete Integration</span>
</HorizontalGroup>
</Text>
</div>
</div>
</WithPermissionControlTooltip>
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} listBorder={2} listWidth={200} withBackground />}
</WithContextMenu>
</div>
)}
>
{({ openMenu }) => <HamburgerMenu openMenu={openMenu} listBorder={2} listWidth={200} withBackground />}
</WithContextMenu>
</div>
</div>
</>
);

View file

@ -57,7 +57,7 @@
{
"type": "page",
"name": "Integrations",
"path": "/a/grafana-oncall-app/integrations",
"path": "/a/grafana-oncall-app/integrations?tab=connections",
"role": "Viewer",
"action": "grafana-oncall-app.integrations:read",
"addToNav": true

View file

@ -3193,15 +3193,12 @@
resolved "https://registry.yarnpkg.com/@petamoriken/float16/-/float16-3.6.6.tgz#641f73913a6be402b34e4bdfca98d6832ed55586"
integrity sha512-3MUulwMtsdCA9lw8a/Kc0XDBJJVCkYTQ5aGd+///TbfkOMXoOGAzzoiYKwPEsLYZv7He7fKJ/mCacqKOO7REyg==
"@playwright/test@^1.35.1":
version "1.35.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.35.1.tgz#a596b61e15b980716696f149cc7a2002f003580c"
integrity sha512-b5YoFe6J9exsMYg0pQAobNDR85T1nLumUYgUTtKm4d21iX2L7WqKq9dW8NGJ+2vX0etZd+Y7UeuqsxDXm9+5ZA==
"@playwright/test@^1.39.0":
version "1.39.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.39.0.tgz#d10ba8e38e44104499e25001945f07faa9fa91cd"
integrity sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==
dependencies:
"@types/node" "*"
playwright-core "1.35.1"
optionalDependencies:
fsevents "2.3.2"
playwright "1.39.0"
"@polka/url@^1.0.0-next.20":
version "1.0.0-next.21"
@ -11757,10 +11754,19 @@ pkg-up@^3.1.0:
dependencies:
find-up "^3.0.0"
playwright-core@1.35.1:
version "1.35.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.35.1.tgz#52c1e6ffaa6a8c29de1a5bdf8cce0ce290ffb81d"
integrity sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==
playwright-core@1.39.0:
version "1.39.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.39.0.tgz#efeaea754af4fb170d11845b8da30b2323287c63"
integrity sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==
playwright@1.39.0:
version "1.39.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.39.0.tgz#184c81cd6478f8da28bcd9e60e94fcebf566e077"
integrity sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==
dependencies:
playwright-core "1.39.0"
optionalDependencies:
fsevents "2.3.2"
please-upgrade-node@^3.2.0:
version "3.2.0"