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

test: env variable config and automatic setServerAddress [TESTENG-49] #9582

Merged
merged 6 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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') });
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@johnkim-det can you try .env with this change?


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);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this was the magic i needed

}
}

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: [
Copy link
Contributor Author

@JComins000 JComins000 Jun 27, 2024

Choose a reason for hiding this comment

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

now we automatically use a dev command on the UI for each page we get (every test). the dev command is set to local storage

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
Loading