Skip to content

Commit

Permalink
test: user-page-models (#9084)
Browse files Browse the repository at this point in the history
  • Loading branch information
JComins000 authored Apr 4, 2024
1 parent 75b1ff4 commit 2c6fec7
Show file tree
Hide file tree
Showing 36 changed files with 934 additions and 100 deletions.
2 changes: 1 addition & 1 deletion webui/react/src/components/AddUsersToGroupsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const AddUsersToGroupsModalComponent = ({
label="Groups"
name={GROUPS_NAME}
rules={[{ message: 'This field is required', required: true }]}>
<Select mode="multiple" placeholder="Select Groups">
<Select data-testid="groups" mode="multiple" placeholder="Select Groups">
{groupOptions.map((go) => (
<Option key={go.group.groupId} value={go.group.groupId}>
{go.group.name}
Expand Down
2 changes: 1 addition & 1 deletion webui/react/src/components/ChangeUserStatusModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const ChangeUserStatusModalComponent = ({
label="Status"
name={STATUS_NAME}
rules={[{ message: 'This field is required', required: true }]}>
<Select allowClear placeholder="Select Status">
<Select allowClear data-testid="status" placeholder="Select Status">
<Option value={StatusType.Activate}>Activate</Option>
<Option value={StatusType.Deactivate}>Deactivate</Option>
</Select>
Expand Down
25 changes: 21 additions & 4 deletions webui/react/src/components/CreateUserModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ const CreateUserModalComponent: React.FC<Props> = ({
return (
<Modal
cancel
data-test-component="createUserModal"
size="small"
submit={{
disabled: hasErrors(form),
Expand All @@ -164,13 +165,28 @@ const CreateUserModalComponent: React.FC<Props> = ({
name={USER_NAME_NAME}
required
validateTrigger={['onSubmit']}>
<Input autoFocus disabled={!!user} maxLength={128} placeholder="User Name" />
<Input
autoFocus
data-testid="username"
disabled={!!user}
maxLength={128}
placeholder="User name"
/>
</Form.Item>
<Form.Item label={DISPLAY_NAME_LABEL} name={DISPLAY_NAME_NAME}>
<Input disabled={viewOnly} maxLength={128} placeholder="Display Name" />
<Input
data-testid="displayName"
disabled={viewOnly}
maxLength={128}
placeholder="Display Name"
/>
</Form.Item>
{!rbacEnabled && (
<Form.Item label={ADMIN_LABEL} name={ADMIN_NAME} valuePropName="checked">
<Form.Item
data-testid="isAdmin"
label={ADMIN_LABEL}
name={ADMIN_NAME}
valuePropName="checked">
<Toggle disabled={viewOnly} />
</Form.Item>
)}
Expand All @@ -180,13 +196,14 @@ const CreateUserModalComponent: React.FC<Props> = ({
label={REMOTE_LABEL}
name={REMOTE_NAME}
valuePropName="checked">
<Toggle disabled={viewOnly} />
<Toggle data-testid="isRemote" disabled={viewOnly} />
</Form.Item>
)}
{rbacEnabled && canModifyPermissions && (
<>
<Form.Item label={ROLE_LABEL} name={ROLE_NAME}>
<Select
data-testid="roles"
disabled={(user !== undefined && userRoles?.isNotLoaded) || viewOnly}
loading={Loadable.isNotLoaded(knownRoles)}
mode="multiple"
Expand Down
2 changes: 1 addition & 1 deletion webui/react/src/components/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const Navigation: React.FC<Props> = ({ children }) => {

return (
<Spinner spinning={ui.showSpinner}>
<div className={css.base}>
<div className={css.base} data-test-component="navigation">
<NavigationSideBar />
{children}
<NavigationTabbar />
Expand Down
4 changes: 2 additions & 2 deletions webui/react/src/components/NavigationSideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,10 @@ const NavigationSideBar: React.FC = () => {
in={settings.navbarCollapsed}
nodeRef={nodeRef}
timeout={200}>
<nav className={css.base} ref={nodeRef}>
<nav className={css.base} data-testid="navSidebar" ref={nodeRef}>
<header>
<Dropdown menu={menuItems}>
<div className={css.user}>
<div className={css.user} data-testid="headerDropdown">
<UserBadge compact hideAvatarTooltip user={currentUser} />
</div>
</Dropdown>
Expand Down
1 change: 1 addition & 0 deletions webui/react/src/components/SetUserRolesModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const SetUserRolesModalComponent = ({
name={ROLE_NAME}
rules={[{ message: 'This field is required', required: true }]}>
<Select
data-testid="roles"
loading={Loadable.isNotLoaded(knownRoles)}
mode="multiple"
placeholder="Select Roles">
Expand Down
4 changes: 2 additions & 2 deletions webui/react/src/components/SkeletonSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ const SkeletonSection: React.FC<Props> = ({
}, [children, contentType]);

return (
<div className={classes.join(' ')}>
<div className={classes.join(' ')} data-test-component="skeletonSection">
{showHeader && (
<div className={css.header}>
<div className={css.header} data-testid="skeletonHeader">
{titleSkeleton}
{filterSkeleton}
</div>
Expand Down
14 changes: 9 additions & 5 deletions webui/react/src/components/Table/InteractiveTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ interface HeaderCellProps {
}

interface CellProps {
children?: React.ReactNode;
className?: string;
isCellRightClickable?: boolean;
'children'?: React.ReactNode;
'className'?: string;
'isCellRightClickable'?: boolean;
'data-testid'?: string;
}

/*
Expand Down Expand Up @@ -284,12 +285,14 @@ const HeaderCell = ({

if (!columnName) return <th className={className} {...props} />;

if (!interactiveColumns) return <th className={headerCellClasses.join(' ')} {...props} />;
if (!interactiveColumns)
return <th className={headerCellClasses.join(' ')} data-testid={columnName} {...props} />;

const tableCell = (
<th className={headerCellClasses.join(' ')}>
<div
className={`${className} ${css.columnDraggingDiv}`}
data-testid={columnName}
ref={drag}
title={columnName}
onClick={(e) => e.stopPropagation()}
Expand Down Expand Up @@ -653,7 +656,7 @@ const InteractiveTable = <
}, [settings.columnWidths, widthData, getUpscaledWidths]);

return (
<div className={css.tableContainer} ref={tableRef}>
<div className={css.tableContainer} data-test-component="interactiveTable" ref={tableRef}>
<Spinner spinning={!!spinning}>
{spinning || !settings ? (
<SkeletonTable columns={renderColumns?.length} />
Expand All @@ -662,6 +665,7 @@ const InteractiveTable = <
bordered
columns={renderColumns}
components={components}
data-testid="table"
dataSource={dataSource}
rowKey={rowKey}
scroll={scroll}
Expand Down
2 changes: 1 addition & 1 deletion webui/react/src/components/Table/SkeletonTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const SkeletonTable: React.FC<Props> = ({ columns = 10, rows = 10, ...props }: P
}, [columns]);
return (
<SkeletonSection {...props}>
<div className={css.base}>
<div className={css.base} data-testid="skeletonTable">
{new Array(rows).fill(null).map((_, rowIndex) => (
<div className={css.row} key={rowIndex}>
{columnProps.map((colProps, colIndex) => (
Expand Down
3 changes: 2 additions & 1 deletion webui/react/src/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ If you don't want to use dev cluster, you can use det deploy to initiate the bac
- Use whatever det-version you want here.
2. `SERVER_ADDRESS="http://localhost:3001" npm run build --prefix webui/react`
3. Optional if you want an experiment created for the test: `det experiment create ./examples/tutorials/mnist_pytorch/const.yaml ./examples/tutorials/mnist_pytorch/`
4. `npm run preview --prefix webui/react` to run the preview app. Not necessary if `CI=true`.
4. Optional `npm run preview --prefix webui/react` to run the preview app. Won't be used if `CI=true`.
1. Consider running `npm run start --prefix webui/react -- --port=3001` for live changes if you're editing page models. The other command will constantly throw build errors if you're editing tests and test hooks at the same time. We use port `3001` because that's the port playwright is configured to use.
5. To run the tests: `PW_SERVER_ADDRESS="http://localhost:3001" PW_USER_NAME="admin" PW_PASSWORD="" npm run e2e --prefix webui/react`
- Provice `-- -p=firefox` to choose one browser to run on. Full list of projects located in [playwright config](/webui/react/playwright.config.ts).

Expand Down
4 changes: 2 additions & 2 deletions webui/react/src/e2e/fixtures/auth.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export class AuthFixture {
}

async logout(): Promise<void> {
await this.#page.locator('header').getByText(this.#USERNAME).click();
await this.#page.getByRole('link', { name: 'Sign Out' }).click();
await this.signInPage.nav.sidebar.headerDropdown.pwLocator.click();
await this.signInPage.nav.sidebar.headerDropdown.signOut.pwLocator.click();
await this.#page.waitForURL(/login/);
}
}
24 changes: 23 additions & 1 deletion webui/react/src/e2e/fixtures/dev.fixture.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Page } from '@playwright/test';
import { expect, Page } from '@playwright/test';

import { BaseComponent, parentTypes } from 'e2e/models/BaseComponent';
import { BasePage } from 'e2e/models/BasePage';

export class DevFixture {
readonly #page: Page;
Expand All @@ -11,4 +14,23 @@ export class DevFixture {
await this.#page.evaluate(`dev.setServerAddress("${process.env.PW_SERVER_ADDRESS}")`);
await this.#page.reload();
}

/**
* Attempts to locate each element in the locator tree. If there is an error at this step,
* the last locator in the error message is the locator that couldn't be found and needs
* to be debugged. If there is no error message, the component could be located and this
* debug line can be removed.
* @param {BaseComponent} component - The component to debug
*/
debugComponentVisible(component: BaseComponent): void {
const componentTree: parentTypes[] = [];
let root: parentTypes = component;
while (!(root instanceof BasePage)) {
componentTree.unshift(root);
root = root._parent;
}
componentTree.forEach(async (node) => {
await expect(node.pwLocator).toBeVisible();
});
}
}
97 changes: 76 additions & 21 deletions webui/react/src/e2e/models/BaseComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,29 @@ import { type Locator } from '@playwright/test';
import { BasePage } from './BasePage';

// BasePage is the root of any tree, use `instanceof BasePage` when climbing.
type parentTypes = BasePage | BaseComponent | BaseReactFragment;
export type parentTypes = BasePage | BaseComponent | BaseReactFragment;

interface ComponentBasics {
parent: parentTypes;
}

interface NamedComponentWithDefaultSelector extends ComponentBasics {
attachment?: never;
sleector?: never;
}
interface NamedComponentWithAttachment extends ComponentBasics {
attachment: string;
sleector?: never;
}
export interface BaseComponentArgs extends ComponentBasics {
attachment?: never;
selector: string;
}

export type NamedComponentArgs =
| BaseComponentArgs
| NamedComponentWithDefaultSelector
| NamedComponentWithAttachment;

/**
* Returns the representation of a Component.
Expand All @@ -17,26 +39,33 @@ export class BaseComponent {
readonly _parent: parentTypes;
protected _locator: Locator | undefined;

constructor({ parent, selector }: { parent: parentTypes; selector: string }) {
constructor({ parent, selector }: BaseComponentArgs) {
this._selector = selector;
this._parent = parent;
}

get selector(): string {
return this._selector;
}

/**
* The playwright Locator that represents this model
*/
get pwLocator(): Locator {
if (this._locator === undefined) {
// Treat the locator as a readonly, but only after we've created it
this._locator = this._parent.pwLocator.locator(this._selector);
this._locator = this._parent.pwLocator.locator(this.selector);
}
return this._locator;
}

/**
* Returns the root of the component tree
*/
get root(): BasePage {
let root: parentTypes = this._parent;
for (; !(root instanceof BasePage); root = root._parent) {
/* empty */
while (!(root instanceof BasePage)) {
root = root._parent;
}
return root;
}
Expand All @@ -52,38 +81,64 @@ export class BaseComponent {
export class BaseReactFragment {
readonly _parent: parentTypes;

constructor({ parent }: { parent: parentTypes }) {
constructor({ parent }: ComponentBasics) {
this._parent = parent;
}

/**
* The playwright Locator that represents this model
* Since this model is a fragment, we simply get the parent's locator
*/
get pwLocator(): Locator {
return this._parent.pwLocator;
}
}

export type NamedComponentArgs = {
parent: parentTypes;
selector?: string;
};
/**
* Returns the root of the component tree
*/
get root(): BasePage {
let root: parentTypes = this._parent;
while (!(root instanceof BasePage)) {
root = root._parent;
}
return root;
}
}

/**
* Returns a representation of a named component.
* This class enforces that a `static defaultSelector` and `static url` be declared
* Returns a representation of a named component. These components need a defaultSelector.
* @param {object} obj
* @param {parentTypes} obj.parent - The parent used to locate this NamedComponent
* @param {string} obj.selector - Used as a selector uesd to locate this object
*/
export abstract class NamedComponent extends BaseComponent {
constructor({ parent, selector }: { parent: parentTypes; selector: string }) {
super({ parent, selector });
const requiredStaticProperties: string[] = ['defaultSelector'];
requiredStaticProperties.forEach((requiredProp) => {
if (!Object.hasOwn(this.constructor, requiredProp)) {
throw new Error(`A named component must declare a static ${requiredProp}!`);
}
});
abstract readonly defaultSelector: string;
readonly #attachment: string;

override get selector(): string {
return this._selector || this.defaultSelector + this.#attachment;
}

static getSelector(args: NamedComponentArgs): { selector: string; attachment: string } {
if (NamedComponent.isBaseComponentArgs(args))
return { attachment: '', selector: args.selector };
if (NamedComponent.isNamedComponentWithAttachment(args))
return { attachment: args.attachment, selector: '' };
else return { attachment: '', selector: '' };
}

static isBaseComponentArgs(args: NamedComponentArgs): args is BaseComponentArgs {
return 'selector' in args;
}

static isNamedComponentWithAttachment(
args: NamedComponentArgs,
): args is NamedComponentWithAttachment {
return 'attachment' in args;
}
constructor(args: NamedComponentArgs) {
const { selector, attachment } = NamedComponent.getSelector(args);
super({ parent: args.parent, selector });
this.#attachment = attachment;
}
}
Loading

0 comments on commit 2c6fec7

Please sign in to comment.