add first multi-role e2e tests (#2417)

# What this PR does

Lays ground work for #1586. Adds three new fixtures, `adminRolePage`,
`editorRolePage`, and `viewerRolePage`. These fixtures can be easily
accessed in a `test` context and allow the test to be run as a user
authenticated with one of these Grafana basic roles.

The bulk of the changes in the PR are to the "global setup" step. There
is a bit of logic + communication with the Grafana instance's API, in
order to create all the necessary authentication credentials.

Lastly, adds the first basic role authorization test, asserting that
Admin/Editors can view the list of OnCall users, whereas Viewers cannot.

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [ ] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Joey Orlando 2023-07-04 11:19:14 +02:00 committed by GitHub
parent 44d0252ef1
commit f5495ed702
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 351 additions and 113 deletions

View file

@ -468,8 +468,12 @@ jobs:
# hit 172.17.0.1 which proxies the request onto the host where port 30001 is the node port that is mapped
# to the OnCall API
ONCALL_API_URL: http://172.17.0.1:30001
GRAFANA_USERNAME: oncall
GRAFANA_PASSWORD: oncall
GRAFANA_ADMIN_USERNAME: oncall
GRAFANA_ADMIN_PASSWORD: oncall
GRAFANA_EDITOR_USERNAME: editor
GRAFANA_EDITOR_PASSWORD: editor
GRAFANA_VIEWER_USERNAME: viewer
GRAFANA_VIEWER_PASSWORD: viewer
MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }}
working-directory: ./grafana-plugin
run: yarn test:integration

View file

@ -192,7 +192,7 @@ To run these tests locally simply do the following:
```bash
npx playwright install # install playwright dependencies
cp ./grafana-plugin/.env.example ./grafana-plugin/.env
cp ./grafana-plugin/integration-tests/.env.example ./grafana-plugin/integration-tests/.env
# you may need to tweak the values in ./grafana-plugin/.env according to your local setup
cd grafana-plugin
yarn test:integration

View file

@ -1,8 +0,0 @@
# copy this file to ./.env and fill out the values according to your local setup
# for integration test purposes
BASE_URL=http://localhost:3000
ONCALL_API_URL=http://host.docker.internal:8080/
GRAFANA_USERNAME=oncall
GRAFANA_PASSWORD=oncall
IS_OPEN_SOURCE=True

View file

@ -0,0 +1 @@
*.json

View file

@ -0,0 +1,9 @@
BASE_URL=http://localhost:3000
ONCALL_API_URL=http://host.docker.internal:8080/
GRAFANA_VIEWER_USERNAME=viewer
GRAFANA_VIEWER_PASSWORD=viewer
GRAFANA_EDITOR_USERNAME=editor
GRAFANA_EDITOR_PASSWORD=editor
GRAFANA_ADMIN_USERNAME=oncall
GRAFANA_ADMIN_PASSWORD=oncall
IS_OPEN_SOURCE=True

View file

@ -1,19 +1,20 @@
import { test } from '@playwright/test';
import { test } from '../fixtures';
import { verifyThatAlertGroupIsTriggered } from '../utils/alertGroup';
import { createEscalationChain, EscalationStep } from '../utils/escalationChain';
import { generateRandomValue } from '../utils/forms';
import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
import { createOnCallSchedule } from '../utils/schedule';
test('we can create an oncall schedule + receive an alert', async ({ page }) => {
test('we can create an oncall schedule + receive an alert', async ({ adminRolePage }) => {
// this test does a lot of stuff, lets give it adequate time to do its thing
test.slow();
const { page, userName } = adminRolePage;
const escalationChainName = generateRandomValue();
const integrationName = generateRandomValue();
const onCallScheduleName = generateRandomValue();
await createOnCallSchedule(page, onCallScheduleName);
await createOnCallSchedule(page, onCallScheduleName, userName);
await createEscalationChain(
page,
escalationChainName,

View file

@ -1,5 +1,4 @@
import { test, expect } from '@playwright/test';
import { GRAFANA_USERNAME } from '../utils/constants';
import { test, expect } from '../fixtures';
import { createEscalationChain, EscalationStep } from '../utils/escalationChain';
import { generateRandomValue } from '../utils/forms';
import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
@ -7,14 +6,15 @@ import { waitForSms } from '../utils/phone';
import { configureUserNotificationSettings, verifyUserPhoneNumber } from '../utils/userSettings';
// TODO: enable once we've signed up for a MailSlurp account to receieve SMSes
test.skip('we can verify our phone number + receive an SMS alert', async ({ page }) => {
test.skip('we can verify our phone number + receive an SMS alert', async ({ adminRolePage }) => {
const { page, userName } = adminRolePage;
const escalationChainName = generateRandomValue();
const integrationName = generateRandomValue();
await verifyUserPhoneNumber(page);
await configureUserNotificationSettings(page, 'SMS');
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, GRAFANA_USERNAME);
await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName);
await createIntegrationAndSendDemoAlert(page, integrationName, escalationChainName);
// wait for the SMS alert notification to arrive

View file

@ -1,4 +1,4 @@
import { test, expect, Page } from '@playwright/test';
import { test, expect, Page } from '../fixtures';
import { generateRandomValue } from '../utils/forms';
import { createEscalationChain } from '../utils/escalationChain';
@ -16,7 +16,9 @@ const assertEscalationChainSearchWorks = async (
};
// TODO: add tests for the new filtering. Commented out as this search doesn't exist anymore
test.skip('searching allows case-insensitive partial matches', async ({ page }) => {
test.skip('searching allows case-insensitive partial matches', async ({ adminRolePage }) => {
const { page } = adminRolePage;
const escalationChainName = `${generateRandomValue()} ${generateRandomValue()}`;
const [firstHalf, secondHalf] = escalationChainName.split(' ');

View file

@ -0,0 +1,53 @@
import { test as base, Page } 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';
export class BaseRolePage {
page: Page;
userName: string;
constructor(page: Page) {
this.page = page;
}
}
class ViewerRolePage extends BaseRolePage {
userName = GRAFANA_VIEWER_USERNAME;
}
class EditorRolePage extends BaseRolePage {
userName = GRAFANA_EDITOR_USERNAME;
}
class AdminRolePage extends BaseRolePage {
userName = GRAFANA_ADMIN_USERNAME;
}
type Fixtures = {
viewerRolePage: ViewerRolePage;
editorRolePage: EditorRolePage;
adminRolePage: AdminRolePage;
};
export * from '@playwright/test';
export const test = base.extend<Fixtures>({
viewerRolePage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: VIEWER_USER_STORAGE_STATE });
const page = new ViewerRolePage(await context.newPage());
await use(page);
await context.close();
},
editorRolePage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: EDITOR_USER_STORAGE_STATE });
const page = new EditorRolePage(await context.newPage());
await use(page);
await context.close();
},
adminRolePage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: ADMIN_USER_STORAGE_STATE });
const page = new AdminRolePage(await context.newPage());
await use(page);
await context.close();
},
});

View file

@ -1,37 +1,57 @@
import { test as setup, chromium, FullConfig, expect, Page, BrowserContext, APIResponse } from '@playwright/test';
import { test as setup, chromium, expect, Page, BrowserContext, FullConfig, APIRequestContext } from '@playwright/test';
import { BASE_URL, GRAFANA_PASSWORD, GRAFANA_USERNAME, IS_OPEN_SOURCE, ONCALL_API_URL } from './utils/constants';
import GrafanaAPIClient from './utils/clients/grafana';
import {
GRAFANA_ADMIN_PASSWORD,
GRAFANA_ADMIN_USERNAME,
GRAFANA_EDITOR_PASSWORD,
GRAFANA_EDITOR_USERNAME,
GRAFANA_VIEWER_PASSWORD,
GRAFANA_VIEWER_USERNAME,
IS_CLOUD,
IS_OPEN_SOURCE,
ONCALL_API_URL,
} from './utils/constants';
import { clickButton, getInputByName } from './utils/forms';
import { goToGrafanaPage } from './utils/navigation';
import { STORAGE_STATE } from '../playwright.config';
import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config';
import { OrgRole } from '@grafana/data';
const IS_CLOUD = !IS_OPEN_SOURCE;
const GLOBAL_SETUP_RETRIES = 3;
const grafanaApiClient = new GrafanaAPIClient(GRAFANA_ADMIN_USERNAME, GRAFANA_ADMIN_PASSWORD);
const makeGrafanaLoginRequest = async (browserContext: BrowserContext): Promise<APIResponse> =>
browserContext.request.post(`${BASE_URL}/login`, {
data: {
user: GRAFANA_USERNAME,
password: GRAFANA_PASSWORD,
},
});
type UserCreationSettings = {
adminAuthedRequest: APIRequestContext;
role: OrgRole;
};
const pollGrafanaInstanceUntilItIsHealthy = async (browserContext: BrowserContext): Promise<boolean> => {
console.log('Polling the grafana instance to make sure it is healthy');
const res = await makeGrafanaLoginRequest(browserContext);
if (!res.ok()) {
console.log(`Grafana instance is unavailable. Got HTTP ${res.status()}. Will wait 5 seconds and then try again`);
await new Promise((resolve) => setTimeout(resolve, 5000));
return pollGrafanaInstanceUntilItIsHealthy(browserContext);
const generateLoginStorageStateAndOptionallCreateUser = async (
config: FullConfig,
userName: string,
password: string,
storageStateFileLocation: string,
userCreationSettings?: UserCreationSettings,
closeContext = false
): Promise<BrowserContext> => {
if (userCreationSettings !== undefined && IS_OPEN_SOURCE) {
const { adminAuthedRequest, role } = userCreationSettings;
await grafanaApiClient.idempotentlyCreateUserWithRole(adminAuthedRequest, userName, password, role);
}
console.log('Grafana instance is available');
return true;
const { headless } = config.projects[0]!.use;
const browser = await chromium.launch({ headless, slowMo: headless ? 0 : 100 });
const browserContext = await browser.newContext();
await grafanaApiClient.login(browserContext.request, userName, password);
await browserContext.storageState({ path: storageStateFileLocation });
if (closeContext) {
await browserContext.close();
}
return browserContext;
};
/**
* go to config page and wait for plugin icon to be available on left-hand navigation
go to config page and wait for plugin icon to be available on left-hand navigation
*/
const configureOnCallPlugin = async (page: Page): Promise<void> => {
/**
@ -66,55 +86,7 @@ const configureOnCallPlugin = async (page: Page): Promise<void> => {
* Borrowed from our friends on the Incident team
* https://github.com/grafana/incident/blob/main/plugin/e2e/global-setup.ts
*/
const globalSetup = async (config: FullConfig): Promise<void> => {
const { headless } = config.projects[0]!.use;
const browser = await chromium.launch({ headless, slowMo: headless ? 0 : 100 });
const browserContext = await browser.newContext();
if (IS_CLOUD) {
/**
* check that the grafana instance is available. If HTTP 503 is returned it means the
* instance is currently unavailable. Poll until it is available
*/
await pollGrafanaInstanceUntilItIsHealthy(browserContext);
}
const res = await makeGrafanaLoginRequest(browserContext);
expect(res.ok()).toBeTruthy();
await browserContext.storageState({ path: STORAGE_STATE });
// make sure the plugin has been configured
const page = await browserContext.newPage();
if (IS_OPEN_SOURCE) {
// plugin configuration can safely be skipped for cloud environments
await configureOnCallPlugin(page);
}
await browserContext.close();
};
/**
* Let's retry global setup, in the event that it fails due to an oncall-engine/oncall-celery backend error.
* Sometimes the sync endpoint will randomly return HTTP 500.
* See here for an example CI job which failed global setup
* https://github.com/grafana/oncall/actions/runs/5062712137/jobs/9088529416#step:19:2536
*
* References on retrying playwright global setup
* https://github.com/microsoft/playwright/discussions/11371
*/
const globalSetupWithRetries = async (config: FullConfig): Promise<void> => {
for (let i = 0; i < GLOBAL_SETUP_RETRIES - 1; i++) {
try {
return await globalSetup(config);
} catch (e) {}
}
// One last time, throwing an error if it fails.
await globalSetup(config);
};
setup('Configure Grafana OnCall plugin', async ({}, { config }) => {
setup('Configure Grafana OnCall plugin', async ({ request }, { config }) => {
/**
* Unconditionally marks the setup as "slow", giving it triple the default timeout.
* This is mostly useful for the rare case for Cloud Grafana instances where the instance may be down/unavailable
@ -122,5 +94,47 @@ setup('Configure Grafana OnCall plugin', async ({}, { config }) => {
*/
setup.slow();
await globalSetupWithRetries(config);
if (IS_CLOUD) {
await grafanaApiClient.pollInstanceUntilItIsHealthy(request);
}
const adminBrowserContext = await generateLoginStorageStateAndOptionallCreateUser(
config,
GRAFANA_ADMIN_USERNAME,
GRAFANA_ADMIN_PASSWORD,
ADMIN_USER_STORAGE_STATE
);
const adminPage = await adminBrowserContext.newPage();
const { request: adminAuthedRequest } = adminBrowserContext;
await generateLoginStorageStateAndOptionallCreateUser(
config,
GRAFANA_EDITOR_USERNAME,
GRAFANA_EDITOR_PASSWORD,
EDITOR_USER_STORAGE_STATE,
{
adminAuthedRequest,
role: OrgRole.Editor,
},
true
);
await generateLoginStorageStateAndOptionallCreateUser(
config,
GRAFANA_VIEWER_USERNAME,
GRAFANA_VIEWER_PASSWORD,
VIEWER_USER_STORAGE_STATE,
{
adminAuthedRequest,
role: OrgRole.Viewer,
},
true
);
if (IS_OPEN_SOURCE) {
// plugin configuration can safely be skipped for cloud environments
await configureOnCallPlugin(adminPage);
}
await adminBrowserContext.close();
});

View file

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

View file

@ -1,11 +1,13 @@
import { test, expect } from '@playwright/test';
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 ({ page }) => {
test('default dates in override creation modal are correct', async ({ adminRolePage }) => {
const { page, userName } = adminRolePage;
const onCallScheduleName = generateRandomValue();
await createOnCallSchedule(page, onCallScheduleName);
await createOnCallSchedule(page, onCallScheduleName, userName);
await clickButton({ page, buttonText: 'Add override' });

View file

@ -1,10 +1,12 @@
import { test, expect } from '@playwright/test';
import { test, expect } from '../fixtures';
import { generateRandomValue } from '../utils/forms';
import { createOnCallSchedule } from '../utils/schedule';
test('check schedule quality for simple 1-user schedule', async ({ page }) => {
test('check schedule quality for simple 1-user schedule', async ({ adminRolePage }) => {
const { page, userName } = adminRolePage;
const onCallScheduleName = generateRandomValue();
await createOnCallSchedule(page, onCallScheduleName);
await createOnCallSchedule(page, onCallScheduleName, userName);
/**
* this page.reload() call is a hack to temporarily get around this issue

View file

@ -0,0 +1,34 @@
import { test, expect, Page } from '../fixtures';
import { goToOnCallPage } from '../utils/navigation';
test.describe('view list of users', () => {
const testFlow = async (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/);
}
};
test('admin is allowed to', async ({ adminRolePage }) => {
await testFlow(adminRolePage.page);
});
test('editor is allowed to', async ({ editorRolePage }) => {
await testFlow(editorRolePage.page);
});
test('viewer is not allowed to', async ({ viewerRolePage }) => {
await testFlow(viewerRolePage.page, false);
});
});

View file

@ -0,0 +1,116 @@
import { OrgRole } from '@grafana/data';
import { expect, APIRequestContext } from '@playwright/test';
import { BASE_URL } from '../constants';
type UsersLookupResponse = {
id: number;
};
type CreateUserResponse = {
id: number;
};
class GrafanaApiException extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}
export default class GrafanaAPIClient {
userName: string;
password: string;
constructor(userName: string, password: string) {
this.userName = userName;
this.password = password;
}
get requestHeaders() {
const base64encodedCredentials = Buffer.from(`${this.userName}:${this.password}`).toString('base64');
return {
Authorization: `Basic ${base64encodedCredentials}`,
};
}
/**
* check that the grafana instance is available. If HTTP 503 is returned it means the
* instance is currently unavailable. Poll until it is available
*/
pollInstanceUntilItIsHealthy = async (request: APIRequestContext): Promise<boolean> => {
console.log('Polling the grafana instance to make sure it is healthy');
const res = await request.get(`${BASE_URL}/api/health`);
if (!res.ok()) {
console.log(`Grafana instance is unavailable. Got HTTP ${res.status()}. Will wait 5 seconds and then try again`);
await new Promise((resolve) => setTimeout(resolve, 5000));
return this.pollInstanceUntilItIsHealthy(request);
}
console.log('Grafana instance is available');
return true;
};
getUserIdByUsername = async (request: APIRequestContext, userName: string): Promise<number> => {
const res = await request.get(`${BASE_URL}/api/users/lookup?loginOrEmail=${userName}`, {
headers: this.requestHeaders,
});
expect(res.ok()).toBeTruthy();
const responseData: UsersLookupResponse = await res.json();
return responseData.id;
};
updateUserRole = async (request: APIRequestContext, userId: number, role: OrgRole): Promise<void> => {
const res = await request.patch(`${BASE_URL}/api/org/users/${userId}`, {
data: { role },
headers: this.requestHeaders,
});
expect(res.ok()).toBeTruthy();
};
/**
* Should return one of the following two responses:
* - HTTP 200 - user successfully created
* - HTTP 412 - user w/ this username already exists (fine to ignore this)
*/
idempotentlyCreateUserWithRole = async (
request: APIRequestContext,
userName: string,
password: string,
role: OrgRole
) => {
const res = await request.post(`${BASE_URL}/api/admin/users`, {
data: {
name: `e2e user - ${userName}`,
login: userName,
password,
},
});
let userId: number;
const responseCode = res.status();
if (responseCode === 200) {
// user was just created
const respJson: CreateUserResponse = await res.json();
userId = respJson.id;
} else if (responseCode == 412) {
// user already exists, go fetch their user id
userId = await this.getUserIdByUsername(request, userName);
} else {
throw new GrafanaApiException(
`Received unexpected status code while trying to idempotently create user - HTTP${responseCode}: ${await res.body()}`
);
}
await this.updateUserRole(request, userId, role);
};
login = async (request: APIRequestContext, userName: string, password: string) => {
const res = await request.post(`${BASE_URL}/login`, {
data: { user: userName, password },
});
expect(res.ok()).toBeTruthy();
};
}

View file

@ -1,6 +1,13 @@
export const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
export const ONCALL_API_URL = process.env.ONCALL_API_URL || 'http://host.docker.internal:8080';
export const GRAFANA_USERNAME = process.env.GRAFANA_USERNAME || 'oncall';
export const GRAFANA_PASSWORD = process.env.GRAFANA_PASSWORD || 'oncall';
export const MAILSLURP_API_KEY = process.env.MAILSLURP_API_KEY;
export const GRAFANA_VIEWER_USERNAME = process.env.GRAFANA_VIEWER_USERNAME || 'viewer';
export const GRAFANA_VIEWER_PASSWORD = process.env.GRAFANA_VIEWER_PASSWORD || 'viewer';
export const GRAFANA_EDITOR_USERNAME = process.env.GRAFANA_EDITOR_USERNAME || 'editor';
export const GRAFANA_EDITOR_PASSWORD = process.env.GRAFANA_EDITOR_PASSWORD || 'editor';
export const GRAFANA_ADMIN_USERNAME = process.env.GRAFANA_ADMIN_USERNAME || 'oncall';
export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'oncall';
export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true';
export const IS_CLOUD = !IS_OPEN_SOURCE;

View file

@ -1,10 +1,9 @@
import { Page } from '@playwright/test';
import { GRAFANA_USERNAME } from './constants';
import { clickButton, fillInInput, selectDropdownValue, selectValuePickerValue } from './forms';
import { clickButton, fillInInput, selectDropdownValue } from './forms';
import { goToOnCallPage } from './navigation';
import dayjs from 'dayjs';
export const createOnCallSchedule = async (page: Page, scheduleName: string): Promise<void> => {
export const createOnCallSchedule = async (page: Page, scheduleName: string, userName: string): Promise<void> => {
// go to the schedules page
await goToOnCallPage(page, 'schedules');
@ -24,7 +23,7 @@ export const createOnCallSchedule = async (page: Page, scheduleName: string): Pr
page,
selectType: 'grafanaSelect',
placeholderText: 'Add user',
value: GRAFANA_USERNAME,
value: userName,
});
await clickButton({ page, buttonText: 'Create' });

View file

@ -7,9 +7,11 @@ import { devices } from '@playwright/test';
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
require('dotenv').config();
require('dotenv').config({ path: path.resolve(process.cwd(), 'integration-tests/.env') });
export const STORAGE_STATE = path.join(__dirname, 'integration-tests/storageState.json');
export const VIEWER_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/viewer.json');
export const EDITOR_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/editor.json');
export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/admin.json');
/**
* See https://playwright.dev/docs/test-configuration.
@ -62,7 +64,6 @@ const config: PlaywrightTestConfig = {
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: STORAGE_STATE,
},
dependencies: ['setup'],
},
@ -70,7 +71,6 @@ const config: PlaywrightTestConfig = {
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: STORAGE_STATE,
},
dependencies: ['setup'],
},
@ -78,7 +78,6 @@ const config: PlaywrightTestConfig = {
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: STORAGE_STATE,
},
dependencies: ['setup'],
},

View file

@ -224,6 +224,7 @@ class Users extends React.Component<UsersProps, UsersState> {
</div>
<GTable
data-testid="users-table"
emptyText={initialUsersLoaded ? 'No users found' : 'Loading...'}
rowKey="pk"
data={results}
@ -246,6 +247,7 @@ class Users extends React.Component<UsersProps, UsersState> {
profile
</>
}
data-testid="view-users-missing-permission-message"
severity="info"
/>
)}