Skip to content

Commit

Permalink
test: env variable config and automatic setServerAddress [TESTENG-49] (
Browse files Browse the repository at this point in the history
…#9582)

Co-authored-by: John Kim <[email protected]>
  • Loading branch information
JComins000 and johnkim-det authored Jul 1, 2024
1 parent 8a47454 commit 98d574e
Show file tree
Hide file tree
Showing 10 changed files with 105 additions and 89 deletions.
15 changes: 8 additions & 7 deletions webui/react/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
import path from 'path';

import { defineConfig, devices } from '@playwright/test';
import * as dotenv from 'dotenv';

dotenv.config();
import { baseUrl } from 'e2e/utils/envVars';

dotenv.config({ path: path.resolve(__dirname, '.env') });

const serverAddess = process.env.PW_SERVER_ADDRESS;
if (serverAddess === undefined) {
throw new Error('Expected PW_SERVER_ADDRESS to be set.');
}
const port = Number(new URL(serverAddess).port || 3001);
const baseURL = baseUrl();
const port = Number(new URL(baseURL).port || 3001);

/**
* See https://playwright.dev/docs/test-configuration.
Expand Down Expand Up @@ -81,7 +82,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5_000,
baseURL: `http://localhost:${port}/`,
baseURL,
navigationTimeout: 10_000,
trace: 'retain-on-failure',
video: 'retain-on-failure',
Expand Down
12 changes: 7 additions & 5 deletions webui/react/src/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ Everything you need before running tests

Create `.env` file in `webui/react` like `webui/react/.env` and env variables. (`PW_` prefix stands for Playwright)

- `PW_USER_NAME`: user name for determined account
- `PW_PASSWORD`: password for determined account
- `PW_SERVER_ADDRESS`: API server address
- `PW_DET_PATH`: path to `det` if not already in path
- `PW_DET_MASTER`: typically <http://localhost:8080>", used for CLI commands
- `PW_BASE_URL`: web server tests are pointing to, typically <http://localhost:3000> or <http://localhost:3001>
- `PW_USERNAME`: admin determined account creds
- `PW_PASSWORD`: admin determined account creds
- `PW_SERVER_ADDRESS`: API server address. defaults to base url
- `PW_DET_PATH`: path to `det`. defaults to `det`
- `PW_DET_MASTER`: used for CLI commands. defaults to <http://localhost:8080>
- `PW_EE`: flag for `ee` or `oss`. defaults to unset for oss

### Playwright

Expand Down
54 changes: 26 additions & 28 deletions webui/react/src/e2e/fixtures/api.auth.fixture.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,29 @@
import { APIRequest, APIRequestContext, Browser, BrowserContext, Page } from '@playwright/test';
import { v4 } from 'uuid';

import { baseUrl, password, username } from 'e2e/utils/envVars';

export class ApiAuthFixture {
apiContext?: APIRequestContext; // we can't get this until login, so may be undefined
readonly request: APIRequest;
readonly browser: Browser;
readonly baseURL: string;
readonly testId = v4();
_page?: Page;
get page(): Page {
if (this._page === undefined) {
throw new Error('Accessing page object before initialization in authentication');
}
return this._page;
}
readonly #STATE_FILE_SUFFIX = 'state.json';
readonly #USERNAME: string;
readonly #PASSWORD: string;
context?: BrowserContext;
readonly #stateFile = `${this.testId}-${this.#STATE_FILE_SUFFIX}`;
browserContext?: BrowserContext;

constructor(request: APIRequest, browser: Browser, baseURL?: string, existingPage?: Page) {
if (process.env.PW_USER_NAME === undefined) {
throw new Error('username must be defined');
}
if (process.env.PW_PASSWORD === undefined) {
throw new Error('password must be defined');
}
if (baseURL === undefined) {
throw new Error('baseURL must be defined in playwright config to use API requests.');
}
this.#USERNAME = process.env.PW_USER_NAME;
this.#PASSWORD = process.env.PW_PASSWORD;
constructor(request: APIRequest, browser: Browser, baseURL: string, existingPage?: Page) {
this.request = request;
this.browser = browser;
this.baseURL = baseURL;
this._page = existingPage;
}

async getBearerToken(): Promise<string> {
async getBearerToken(noBearer = false): Promise<string> {
const cookies = (await this.apiContext?.storageState())?.cookies ?? [];
const authToken = cookies.find((cookie) => {
return cookie.name === 'auth';
Expand All @@ -48,6 +33,7 @@ export class ApiAuthFixture {
'Attempted to retrieve the auth token from the PW apiContext, but it does not exist. Have you called apiAuth.login() yet?',
);
}
if (noBearer) return authToken;
return `Bearer ${authToken}`;
}

Expand All @@ -56,10 +42,8 @@ export class ApiAuthFixture {
* fixture, the bearer token will be attached to that context. If not a new
* browser context will be created with the cookie.
*/
async login({
creds = { password: this.#PASSWORD, username: this.#USERNAME },
} = {}): Promise<void> {
this.apiContext = this.apiContext || (await this.request.newContext());
async loginApi({ creds = { password: password(), username: username() } } = {}): Promise<void> {
this.apiContext = this.apiContext || (await this.request.newContext({ baseURL: this.baseURL }));
const resp = await this.apiContext.post('/api/v1/auth/login', {
data: {
...creds,
Expand All @@ -69,12 +53,26 @@ export class ApiAuthFixture {
if (resp.status() !== 200) {
throw new Error(`Login API request has failed with status code ${resp.status()}`);
}
}

async loginBrowser(page: Page): Promise<void> {
if (this.apiContext === undefined) {
throw new Error('Cannot login browser without first logging in API');
}
// Save cookie state into the file.
const state = await this.apiContext.storageState({ path: this.#stateFile });
if (this._page !== undefined) {
const state = await this.apiContext.storageState();
// add cookies to current page's existing context
this.context = this._page.context();
await this.context.addCookies(state.cookies);
this.browserContext = this._page.context();
// replace the domain of api base url with browser base url
state.cookies.forEach((cookie) => {
if (cookie.name === 'auth' && cookie.domain === new URL(this.baseURL).hostname) {
cookie.domain = new URL(baseUrl()).hostname;
}
});
await this.browserContext.addCookies(state.cookies);
const token = JSON.stringify(await this.getBearerToken(true));
await page.evaluate((token) => localStorage.setItem('global/auth-token', token), token);
}
}

Expand All @@ -86,6 +84,6 @@ export class ApiAuthFixture {
*/
async dispose(): Promise<void> {
await this.apiContext?.dispose();
await this.context?.close();
await this.browserContext?.close();
}
}
11 changes: 3 additions & 8 deletions webui/react/src/e2e/fixtures/auth.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Page } from '@playwright/test';

import { expect } from 'e2e/fixtures/global-fixtures';
import { SignIn } from 'e2e/models/pages/SignIn';
import { password, username } from 'e2e/utils/envVars';

export class AuthFixture {
readonly #page: Page;
Expand All @@ -10,14 +11,8 @@ export class AuthFixture {
readonly signInPage: SignIn;

constructor(readonly page: Page) {
if (process.env.PW_USER_NAME === undefined) {
throw new Error('username must be defined');
}
if (process.env.PW_PASSWORD === undefined) {
throw new Error('password must be defined');
}
this.#USERNAME = process.env.PW_USER_NAME;
this.#PASSWORD = process.env.PW_PASSWORD;
this.#USERNAME = username();
this.#PASSWORD = password();
this.#page = page;
this.signInPage = new SignIn(page);
}
Expand Down
21 changes: 10 additions & 11 deletions webui/react/src/e2e/fixtures/dev.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,18 @@ import { Page } from '@playwright/test';
import { expect } from 'e2e/fixtures/global-fixtures';
import { BaseComponent, CanBeParent } from 'e2e/models/common/base/BaseComponent';
import { BasePage } from 'e2e/models/common/base/BasePage';
import { apiUrl } from 'e2e/utils/envVars';

export class DevFixture {
readonly #page: Page;
constructor(readonly page: Page) {
this.#page = page;
}
// tells the frontend where to find the backend if built for a different url. Incidentally reloads and logs out of Determined.
async setServerAddress(): Promise<void> {
await this.#page.goto('/');
await this.#page.evaluate(`dev.setServerAddress("${process.env.PW_SERVER_ADDRESS}")`);
await this.#page.reload();
await this.#page.waitForLoadState('networkidle'); // dev.setServerAddress fires a logout request in the background, so we will wait until no network traffic is happening.
}
setServerAddress = async (page: Page): Promise<void> => {
// Tells the frontend where to find the backend if built for a different url.
// Incidentally reloads and logs out of Determined.
await page.goto('/');
await page.evaluate(`dev.setServerAddress("${apiUrl()}")`);
await page.reload();
// dev.setServerAddress fires a logout request in the background, so we will wait until no network traffic is happening.
await page.waitForLoadState('networkidle');
};

/**
* Attempts to locate each element in the locator tree. If there is an error at this step,
Expand Down
40 changes: 22 additions & 18 deletions webui/react/src/e2e/fixtures/global-fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { expect as baseExpect, test as baseTest, Page } from '@playwright/test';

import { apiUrl, isEE } from 'e2e/utils/envVars';
import { safeName } from 'e2e/utils/naming';
import { V1PostUserRequest } from 'services/api-ts-sdk/api';

// eslint-disable-next-line no-restricted-imports
import playwrightConfig from '../../../playwright.config';

import { ApiAuthFixture } from './api.auth.fixture';
import { ApiUserFixture } from './api.user.fixture';
Expand All @@ -14,6 +14,7 @@ import { UserFixture } from './user.fixture';

type CustomFixtures = {
dev: DevFixture;
devSetup: Page;
auth: AuthFixture;
apiAuth: ApiAuthFixture;
user: UserFixture;
Expand All @@ -30,10 +31,9 @@ type CustomWorkerFixtures = {
// https://playwright.dev/docs/test-fixtures
export const test = baseTest.extend<CustomFixtures, CustomWorkerFixtures>({
// get the auth but allow yourself to log in through the api manually.
apiAuth: async ({ playwright, browser, dev, baseURL, newAdmin }, use) => {
await dev.setServerAddress();
const apiAuth = new ApiAuthFixture(playwright.request, browser, baseURL, dev.page);
await apiAuth.login({
apiAuth: async ({ playwright, browser, newAdmin, devSetup }, use) => {
const apiAuth = new ApiAuthFixture(playwright.request, browser, apiUrl(), devSetup);
await apiAuth.loginApi({
creds: {
password: newAdmin.password!,
username: newAdmin.user!.username,
Expand All @@ -54,6 +54,7 @@ export const test = baseTest.extend<CustomFixtures, CustomWorkerFixtures>({

// get the existing page but with auth cookie already logged in
authedPage: async ({ apiAuth }, use) => {
await apiAuth.loginBrowser(apiAuth.page);
await use(apiAuth.page);
},

Expand All @@ -64,20 +65,16 @@ export const test = baseTest.extend<CustomFixtures, CustomWorkerFixtures>({
*/
backgroundApiAuth: [
async ({ playwright, browser }, use) => {
const backgroundApiAuth = new ApiAuthFixture(
playwright.request,
browser,
playwrightConfig.use?.baseURL,
);
await backgroundApiAuth.login();
const backgroundApiAuth = new ApiAuthFixture(playwright.request, browser, apiUrl());
await backgroundApiAuth.loginApi();
await use(backgroundApiAuth);
await backgroundApiAuth.dispose();
},
{ scope: 'worker' },
],
/**
* Allows calling the user api without a page so that it can run in beforeAll(). You will need to get a bearer
* token by calling backgroundApiUser.apiAuth.login(). This will also provision a page in the background which
* token by calling backgroundApiUser.apiAuth.loginAPI(). This will also provision a page in the background which
* will be disposed of logout(). Before using the page,you need to call dev.setServerAddress() manually and
* then login() again, since setServerAddress logs out as a side effect.
*/
Expand All @@ -88,10 +85,18 @@ export const test = baseTest.extend<CustomFixtures, CustomWorkerFixtures>({
},
{ scope: 'worker' },
],
dev: async ({ page }, use) => {
const dev = new DevFixture(page);
// eslint-disable-next-line no-empty-pattern
dev: async ({}, use) => {
const dev = new DevFixture();
await use(dev);
},
devSetup: [
async ({ dev, page }, use) => {
await dev.setServerAddress(page);
await use(page);
},
{ auto: true },
],
/**
* Creates an admin and logs in as that admin for the duraction of the test suite
*/
Expand All @@ -108,11 +113,11 @@ export const test = baseTest.extend<CustomFixtures, CustomWorkerFixtures>({
},
}),
);
await backgroundApiUser.apiAuth.login({
await backgroundApiUser.apiAuth.loginApi({
creds: { password: adminUser.password!, username: adminUser.user!.username },
});
await use(adminUser);
await backgroundApiUser.apiAuth.login();
await backgroundApiUser.apiAuth.loginApi();
await backgroundApiUser.patchUser(adminUser.user!.id!, { active: false });
},
{ scope: 'worker' },
Expand All @@ -130,8 +135,7 @@ export const expect = baseExpect.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let matcherResult: any;

const isEE = Boolean(JSON.parse(process.env.PW_EE ?? '""'));
const appTitle = isEE ? 'HPE Machine Learning Development Environment' : 'Determined';
const appTitle = isEE() ? 'HPE Machine Learning Development Environment' : 'Determined';

const getFullTitle = (prefix: string = '') => {
if (prefix === '') {
Expand Down
3 changes: 0 additions & 3 deletions webui/react/src/e2e/tests/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ import { expect, test } from 'e2e/fixtures/global-fixtures';
import { SignIn } from 'e2e/models/pages/SignIn';

test.describe('Authentication', () => {
test.beforeEach(async ({ dev }) => {
await dev.setServerAddress();
});
test.afterEach(async ({ page, auth }) => {
const signInPage = new SignIn(page);
if ((await page.title()).indexOf(signInPage.title) === -1) {
Expand Down
11 changes: 7 additions & 4 deletions webui/react/src/e2e/tests/experimentList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ test.describe('Experiement List', () => {
return parseInt(expNum);
};

test.beforeAll(async ({ browser }) => {
test.beforeAll(async ({ browser, dev }) => {
const pageSetupTeardown = await browser.newPage();
await dev.setServerAddress(pageSetupTeardown);
const authFixtureSetupTeardown = new AuthFixture(pageSetupTeardown);
const projectDetailsPageSetupTeardown = new ProjectDetails(pageSetupTeardown);
await authFixtureSetupTeardown.login();
Expand Down Expand Up @@ -231,13 +232,13 @@ test.describe('Experiement List', () => {
});

test('Datagrid Functionality Validations', async ({ authedPage }) => {
const row = await projectDetailsPage.f_experiemntList.dataGrid.getRowByColumnValue('ID', '1');
const row = await projectDetailsPage.f_experiemntList.dataGrid.getRowByIndex(0);
await test.step('Select Row', async () => {
await row.clickColumn('Select');
expect.soft(await row.isSelected()).toBeTruthy();
});
await test.step('Read Cell Value', async () => {
await expect.soft((await row.getCellByColumnName('Checkpoints')).pwLocator).toHaveText('0');
await expect.soft((await row.getCellByColumnName('ID')).pwLocator).toHaveText(/\d+/);
});
await test.step('Select 5', async () => {
await (
Expand All @@ -246,8 +247,10 @@ test.describe('Experiement List', () => {
});
await test.step('Experiement Overview Navigation', async () => {
await projectDetailsPage.f_experiemntList.dataGrid.scrollLeft();
const textContent = await (await row.getCellByColumnName('ID')).pwLocator.textContent();
await row.clickColumn('ID');
await authedPage.waitForURL(/overview/);
if (textContent === null) throw new Error('Cannot read row id');
await authedPage.waitForURL(new RegExp(textContent));
});
});

Expand Down
Loading

0 comments on commit 98d574e

Please sign in to comment.