From bb12e2b7500c42917cbff0a58ff72e0dd022bc68 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Mon, 21 Oct 2024 16:54:26 +0200 Subject: [PATCH 1/7] Make auth errors session dependent --- docs/extending-locust.rst | 3 +++ examples/web_ui_auth.py | 2 +- locust/web.py | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/extending-locust.rst b/docs/extending-locust.rst index 55c9f8d3ea..a36622f7b0 100644 --- a/docs/extending-locust.rst +++ b/docs/extending-locust.rst @@ -152,6 +152,9 @@ Whether you are using username / password authentication, an auth provider, or b to the ``login_manager``. The ``user_loader`` should return ``None`` to deny authentication or return a User object when authentication to the app should be granted. +To display errors on the login page, such as an incorrect username / password combination, you may store the ``auth_error`` +on the session object: ``session["auth_error"] = "Incorrect username or password"``. + A full example can be seen `in the auth example `_. diff --git a/examples/web_ui_auth.py b/examples/web_ui_auth.py index c846c19215..650e257834 100644 --- a/examples/web_ui_auth.py +++ b/examples/web_ui_auth.py @@ -76,7 +76,7 @@ def login_submit(): return redirect(url_for("index")) - environment.web_ui.auth_args = {**environment.web_ui.auth_args, "error": "Invalid username or password"} + session["auth_error"] = "Invalid username or password" return redirect(url_for("login")) diff --git a/locust/web.py b/locust/web.py index 4e7ecf11a2..365665539c 100644 --- a/locust/web.py +++ b/locust/web.py @@ -24,6 +24,7 @@ request, send_file, send_from_directory, + session, url_for, ) from flask_cors import CORS @@ -504,6 +505,8 @@ def login(): if not self.web_login: return redirect(url_for("index")) + self.auth_args["error"] = session.get("auth_error", None) + return render_template_from( "auth.html", auth_args=self.auth_args, @@ -573,6 +576,7 @@ def my_custom_route(): def wrapper(*args, **kwargs): if self.web_login: try: + session["auth_error"] = None return login_required(view_func)(*args, **kwargs) except Exception as e: return f"Locust auth exception: {e} See https://docs.locust.io/en/stable/extending-locust.html#adding-authentication-to-the-web-ui for configuring authentication." From ac50b0dcd1247e51a3b104f0770da6f3d1052a4d Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Mon, 21 Oct 2024 17:46:10 +0200 Subject: [PATCH 2/7] Add custom form option for auth form --- .../webui/src/components/Form/CustomInput.tsx | 49 ++++++++++++++ .../src/components/Form/PasswordField.tsx | 28 ++++++++ .../SwarmForm/SwarmCustomParameters.tsx | 67 ++++--------------- locust/webui/src/pages/Auth.tsx | 60 +++++++---------- locust/webui/src/types/auth.types.ts | 7 ++ locust/webui/src/types/form.types.ts | 7 ++ locust/webui/src/types/swarm.types.ts | 6 +- 7 files changed, 130 insertions(+), 94 deletions(-) create mode 100644 locust/webui/src/components/Form/CustomInput.tsx create mode 100644 locust/webui/src/components/Form/PasswordField.tsx create mode 100644 locust/webui/src/types/form.types.ts diff --git a/locust/webui/src/components/Form/CustomInput.tsx b/locust/webui/src/components/Form/CustomInput.tsx new file mode 100644 index 0000000000..123f3e59a7 --- /dev/null +++ b/locust/webui/src/components/Form/CustomInput.tsx @@ -0,0 +1,49 @@ +import { Checkbox, FormControlLabel, TextField } from '@mui/material'; + +import PasswordField from 'components/Form/PasswordField'; +import Select from 'components/Form/Select'; +import { ICustomInput } from 'types/form.types'; + +export default function CustomInput({ + name, + label, + defaultValue, + choices, + isSecret, +}: ICustomInput) { + if (choices) { + return ( + - ); - } - - if (typeof defaultValue === 'boolean') { - return ( - } - label={labelWithOptionalHelpText} - name={label} - /> - ); - } - - return ( - - ); -} - export default function CustomParameters({ extraOptions }: ICustomParameters) { return ( @@ -68,7 +18,16 @@ export default function CustomParameters({ extraOptions }: ICustomParameters) { {Object.entries(extraOptions).map(([label, inputProps], index) => ( - + ))} diff --git a/locust/webui/src/pages/Auth.tsx b/locust/webui/src/pages/Auth.tsx index 57709762d7..cbb59ad46c 100644 --- a/locust/webui/src/pages/Auth.tsx +++ b/locust/webui/src/pages/Auth.tsx @@ -1,30 +1,20 @@ -import { useState } from 'react'; -import { Visibility, VisibilityOff } from '@mui/icons-material'; -import { - Alert, - Box, - Button, - FormControl, - IconButton, - InputAdornment, - InputLabel, - OutlinedInput, - TextField, - Typography, -} from '@mui/material'; +import { Alert, Box, Button, IconButton, TextField, Typography } from '@mui/material'; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider } from '@mui/material/styles'; import Logo from 'assets/Logo'; +import CustomInput from 'components/Form/CustomInput'; +import PasswordField from 'components/Form/PasswordField'; import DarkLightToggle from 'components/Layout/Navbar/DarkLightToggle'; import useCreateTheme from 'hooks/useCreateTheme'; import { IAuthArgs } from 'types/auth.types'; -export default function Auth({ authProviders, error, usernamePasswordCallback }: IAuthArgs) { - const [showPassword, setShowPassword] = useState(false); - - const handleClickShowPassword = () => setShowPassword(!showPassword); - +export default function Auth({ + authProviders, + customForm, + error, + usernamePasswordCallback, +}: IAuthArgs) { const theme = useCreateTheme(); return ( @@ -53,26 +43,11 @@ export default function Auth({ authProviders, error, usernamePasswordCallback }: - {usernamePasswordCallback && ( + {usernamePasswordCallback && !customForm && (
- - Password - - - {showPassword ? : } - - - } - id='password-field' - label='Password' - name='password' - type={showPassword ? 'text' : 'password'} - /> - + {error && {error}} +
+ + )} {authProviders && ( {authProviders.map(({ label, callbackUrl, iconUrl }, index) => ( diff --git a/locust/webui/src/types/auth.types.ts b/locust/webui/src/types/auth.types.ts index 41b51ec94f..152df502f6 100644 --- a/locust/webui/src/types/auth.types.ts +++ b/locust/webui/src/types/auth.types.ts @@ -1,3 +1,5 @@ +import { ICustomInput } from 'types/form.types'; + export interface IAuthProviders { label: string; iconUrl: string; @@ -8,4 +10,9 @@ export interface IAuthArgs { usernamePasswordCallback?: string; error?: string; authProviders?: IAuthProviders[]; + customForm?: { + inputs: ICustomInput[]; + callbackUrl: string; + submitButtonText?: string; + }; } diff --git a/locust/webui/src/types/form.types.ts b/locust/webui/src/types/form.types.ts new file mode 100644 index 0000000000..6a71cddf83 --- /dev/null +++ b/locust/webui/src/types/form.types.ts @@ -0,0 +1,7 @@ +export interface ICustomInput { + label: string; + name: string; + choices: string[] | null; + defaultValue: string | number | boolean | null; + isSecret: boolean; +} diff --git a/locust/webui/src/types/swarm.types.ts b/locust/webui/src/types/swarm.types.ts index b389ff2125..4688a1f487 100644 --- a/locust/webui/src/types/swarm.types.ts +++ b/locust/webui/src/types/swarm.types.ts @@ -1,3 +1,4 @@ +import { ICustomInput } from 'types/form.types'; import { ITab } from 'types/tab.types'; import { ITableStructure } from 'types/table.types'; import { @@ -9,11 +10,8 @@ import { ISwarmException, } from 'types/ui.types'; -export interface IExtraOptionParameter { - choices: string[] | null; - defaultValue: string | number | boolean | null; +export interface IExtraOptionParameter extends Omit { helpText: string | null; - isSecret: boolean; } export interface IExtraOptions { From c35bf20d93bdf351328048ab3de847edf2a3e998 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Mon, 21 Oct 2024 17:48:17 +0200 Subject: [PATCH 3/7] Update auth examples --- .../{web_ui_auth.py => web_ui_auth/basic.py} | 10 +- examples/web_ui_auth/custom_form.py | 107 ++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) rename examples/{web_ui_auth.py => web_ui_auth/basic.py} (89%) create mode 100644 examples/web_ui_auth/custom_form.py diff --git a/examples/web_ui_auth.py b/examples/web_ui_auth/basic.py similarity index 89% rename from examples/web_ui_auth.py rename to examples/web_ui_auth/basic.py index 650e257834..89fe665c4b 100644 --- a/examples/web_ui_auth.py +++ b/examples/web_ui_auth/basic.py @@ -9,10 +9,9 @@ from locust import HttpUser, events, task -import json import os -from flask import Blueprint, make_response, redirect, request, session, url_for +from flask import Blueprint, redirect, request, session, url_for from flask_login import UserMixin, login_user @@ -33,12 +32,12 @@ def get_id(self): auth_blueprint = Blueprint("auth", "web_ui_auth") -def load_user(user_id): - return AuthUser(session.get("username")) +def load_user(username): + return AuthUser(username) @events.init.add_listener -def locust_init(environment, **kwargs): +def locust_init(environment, **_kwargs): if environment.web_ui: environment.web_ui.login_manager.user_loader(load_user) @@ -71,7 +70,6 @@ def login_submit(): # Implement real password verification here if password: - session["username"] = username login_user(AuthUser(username)) return redirect(url_for("index")) diff --git a/examples/web_ui_auth/custom_form.py b/examples/web_ui_auth/custom_form.py new file mode 100644 index 0000000000..134fe6d63d --- /dev/null +++ b/examples/web_ui_auth/custom_form.py @@ -0,0 +1,107 @@ +""" +Example of implementing authentication with a custom form for Locust when the --web-login +flag is given + +This is only to serve as a starting point, proper authentication should be implemented +according to your projects specifications. + +For more information, see https://docs.locust.io/en/stable/extending-locust.html#authentication +""" + +from __future__ import annotations + +from locust import HttpUser, events, task + +import os + +from flask import Blueprint, redirect, request, session, url_for +from flask_login import UserMixin, login_user + + +class LocustHttpUser(HttpUser): + @task + def example(self): + self.client.get("/") + + +class AuthUser(UserMixin): + def __init__(self, username): + self.username = username + self.is_admin = False + self.user_group: str | None = None + + def get_id(self): + return self.username + + +auth_blueprint = Blueprint("auth", "web_ui_auth") + + +def load_user(user_id): + return AuthUser(user_id) + + +@events.init.add_listener +def locust_init(environment, **_kwargs): + if environment.web_ui: + environment.web_ui.login_manager.user_loader(load_user) + + environment.web_ui.app.config["SECRET_KEY"] = os.getenv("FLASK_SECRET_KEY") + + environment.web_ui.auth_args = { + "custom_form": { + "inputs": [ + { + "label": "Username", + "name": "username", + }, + # boolean checkmark field + {"label": "Admin", "name": "is_admin", "default_value": False}, + # select field + {"label": "User Group", "name": "user_group", "choices": ["developer", "manager"]}, + { + "label": "Password", + "name": "password", + "is_secret": True, + }, + { + "label": "Confirm Password", + "name": "confirm_password", + "is_secret": True, + }, + ], + "callback_url": "/login_submit", + "submit_button_text": "Submit", + }, + } + + @auth_blueprint.route("/login_submit", methods=["POST"]) + def login_submit(): + username = request.form.get("username") + password = request.form.get("password") + confirm_password = request.form.get("confirm_password") + is_admin = request.form.get("is_admin") == "on" + user_group = request.form.get("user_group") + + if password != confirm_password: + session["auth_error"] = "Passwords do not match!" + + return redirect(url_for("login")) + + # Implement real password verification here + if password: + current_user = AuthUser(username) + + # do something with your custom variables + current_user.is_admin = is_admin + current_user.user_group = user_group + + login_user(AuthUser(username)) + + return redirect(url_for("index")) + + session["auth_error"] = "Invalid username or password" + + return redirect(url_for("login")) + + environment.web_ui.app.register_blueprint(auth_blueprint) From 502ffc295d1f61bc436ee6bca5ffbd14fe18e39b Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 22 Oct 2024 15:09:21 +0200 Subject: [PATCH 4/7] Update docs --- docs/extending-locust.rst | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/extending-locust.rst b/docs/extending-locust.rst index a36622f7b0..f6ddbc893f 100644 --- a/docs/extending-locust.rst +++ b/docs/extending-locust.rst @@ -137,13 +137,14 @@ source code. Adding Authentication to the Web UI =================================== -Locust uses `Flask-Login `_ to handle authentication. The ``login_manager`` is -exposed on ``environment.web_ui.app``, allowing the flexibility for you to implement any kind of auth that you would like! +Locust uses `Flask-Login `_ to handle authentication when the ``--web-login`` flag is present. +The ``login_manager`` is exposed on ``environment.web_ui.app``, allowing the flexibility for you to implement any kind of auth that +you would like! To use username / password authentication, simply provide a ``username_password_callback`` to the ``environment.web_ui.auth_args``. You are responsible for defining the route for the callback and implementing the authentication. -Authentication providers can additionally be configured to allow authentication from 3rd parties such as GitHub or an SSO. +Authentication providers can additionally be configured to allow authentication from 3rd parties such as GitHub or an SSO provider. Simply provide a list of desired ``auth_providers``. You may specify the ``label`` and ``icon`` for display on the button. The ``callback_url`` will be the url that the button directs to. You will be responsible for defining the callback route as well as the authentication with the 3rd party. @@ -155,7 +156,14 @@ authentication to the app should be granted. To display errors on the login page, such as an incorrect username / password combination, you may store the ``auth_error`` on the session object: ``session["auth_error"] = "Incorrect username or password"``. -A full example can be seen `in the auth example `_. +A full example can be seen `in the auth example `_. + +In certain situations you may wish to further extend the fields present in the auth form. To achieve this, pass a ``custom_form`` dict +to the ``environment.web_ui.auth_args``. In this case, the fields will be represented by a list of ``inputs``, the callback url is +configured by the ``custom_form.callback_url``, and the submit button may optionally be configured using the ``custom_form.submit_button_text``. +The fields in the auth form may be a text, select, checkbox, or secret password field. + +For a full example see `configuring the custom_form in the auth example `_. Run a background greenlet From 9fe13355200e5614202e145efbf474aec8362204 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 22 Oct 2024 16:13:46 +0200 Subject: [PATCH 5/7] Add CustomInput tests --- .../webui/src/components/Form/CustomInput.tsx | 2 +- .../src/components/Form/PasswordField.tsx | 11 +- .../Form/tests/CustomInput.test.tsx | 128 ++++++++++++++++++ locust/webui/src/types/form.types.ts | 6 +- locust/webui/src/types/swarm.types.ts | 2 +- 5 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 locust/webui/src/components/Form/tests/CustomInput.test.tsx diff --git a/locust/webui/src/components/Form/CustomInput.tsx b/locust/webui/src/components/Form/CustomInput.tsx index 123f3e59a7..0cdaa3e180 100644 --- a/locust/webui/src/components/Form/CustomInput.tsx +++ b/locust/webui/src/components/Form/CustomInput.tsx @@ -34,7 +34,7 @@ export default function CustomInput({ } if (isSecret) { - return ; + return ; } return ( diff --git a/locust/webui/src/components/Form/PasswordField.tsx b/locust/webui/src/components/Form/PasswordField.tsx index e261146779..7c1db53753 100644 --- a/locust/webui/src/components/Form/PasswordField.tsx +++ b/locust/webui/src/components/Form/PasswordField.tsx @@ -2,15 +2,22 @@ import { useState } from 'react'; import { Visibility, VisibilityOff } from '@mui/icons-material'; import { FormControl, IconButton, InputAdornment, InputLabel, OutlinedInput } from '@mui/material'; -export default function PasswordField({ name = 'password', label = 'Password' }) { +import { ICustomInput } from 'types/form.types'; + +export default function PasswordField({ + name = 'password', + label = 'Password', + defaultValue, +}: Pick) { const [showPassword, setShowPassword] = useState(false); const handleClickShowPassword = () => setShowPassword(!showPassword); return ( - {label} + {label} diff --git a/locust/webui/src/components/Form/tests/CustomInput.test.tsx b/locust/webui/src/components/Form/tests/CustomInput.test.tsx new file mode 100644 index 0000000000..a60e11c864 --- /dev/null +++ b/locust/webui/src/components/Form/tests/CustomInput.test.tsx @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'vitest'; + +import CustomInput from 'components/Form/CustomInput'; +import { renderWithProvider } from 'test/testUtils'; + +describe('SwarmCustomParameters', () => { + test('renders a text field', () => { + const fieldName = 'textField'; + const fieldLabel = 'Text Field'; + const fieldDefaultValue = 'value'; + + const { getByLabelText } = renderWithProvider( + , + ); + + const textField = getByLabelText(fieldLabel); + + expect(textField).toBeTruthy(); + expect(textField.getAttribute('name')).toBe(fieldName); + expect(textField.getAttribute('type')).toBe('text'); + expect(textField.getAttribute('value')).toBe(fieldDefaultValue); + }); + + test('renders a password field', () => { + const fieldName = 'passwordField'; + const fieldLabel = 'Password Field'; + const fieldDefaultValue = 'Secret value'; + + const { getByLabelText } = renderWithProvider( + , + ); + + const textField = getByLabelText(fieldLabel); + + expect(textField).toBeTruthy(); + expect(textField.getAttribute('name')).toBe(fieldName); + expect(textField.getAttribute('type')).toBe('password'); + expect(textField.getAttribute('value')).toBe(fieldDefaultValue); + }); + + test('renders a select component when choices are provided', () => { + const fieldName = 'options'; + const fieldLabel = 'Options'; + + const firstCustomChoice = 'Option1'; + const secondCustomChoice = 'Option2'; + + const { getByLabelText, getByText } = renderWithProvider( + , + ); + + const selectField = getByLabelText(fieldLabel); + const option1 = getByText(firstCustomChoice); + const option2 = getByText(secondCustomChoice); + + expect(selectField).toBeTruthy(); + + expect(option1.parentElement instanceof HTMLSelectElement).toBeTruthy(); + expect(option1.parentElement?.getAttribute('name')).toBe(fieldName); + + expect(option1 instanceof HTMLOptionElement).toBeTruthy(); + expect(option2 instanceof HTMLOptionElement).toBeTruthy(); + + // Sets default value + expect(option1.hasAttribute('selected')).toBeTruthy(); + expect(option2.hasAttribute('selected')).toBeFalsy(); + }); + + test('renders a checkbox when a boolean default value is provided', () => { + const fieldTruthyName = 'truthyBoolean'; + const fieldTruthyLabel = 'Truthy Boolean'; + const fieldFalseyName = 'falseyBoolean'; + const fieldFalseyLabel = 'Falsey Boolean'; + + const { getByLabelText } = renderWithProvider( + <> + + + , + ); + + const booleanField = getByLabelText(fieldTruthyLabel) as HTMLInputElement; + const uncheckedBooleanField = getByLabelText(fieldFalseyLabel) as HTMLInputElement; + + expect(booleanField).toBeTruthy(); + expect(booleanField.getAttribute('name')).toBe(fieldTruthyName); + expect(booleanField.getAttribute('type')).toBe('checkbox'); + expect(booleanField.checked).toBeTruthy(); + expect(uncheckedBooleanField.getAttribute('name')).toBe(fieldFalseyName); + expect(uncheckedBooleanField.checked).toBeFalsy(); + }); + + test('allows defaultValue to be null for text, password, and select fields', () => { + const customTextField = 'textField'; + const customPasswordField = 'passwordField'; + const customChoicesField = 'customChoices'; + const firstCustomChoice = 'Option1'; + const secondCustomChoice = 'Option2'; + + const { getByLabelText } = renderWithProvider( + <> + + + + , + ); + + const textField = getByLabelText(customTextField) as HTMLInputElement; + const passwordField = getByLabelText(customPasswordField) as HTMLInputElement; + const choicesField = getByLabelText(customChoicesField) as HTMLInputElement; + + expect(textField).toBeTruthy(); + expect(passwordField).toBeTruthy(); + expect(choicesField).toBeTruthy(); + expect(textField.value).toBe(''); + expect(passwordField.value).toBe(''); + expect(choicesField.value).toBe(firstCustomChoice); + }); +}); diff --git a/locust/webui/src/types/form.types.ts b/locust/webui/src/types/form.types.ts index 6a71cddf83..b533092113 100644 --- a/locust/webui/src/types/form.types.ts +++ b/locust/webui/src/types/form.types.ts @@ -1,7 +1,7 @@ export interface ICustomInput { label: string; name: string; - choices: string[] | null; - defaultValue: string | number | boolean | null; - isSecret: boolean; + choices?: string[] | null; + defaultValue?: string | number | boolean | null; + isSecret?: boolean; } diff --git a/locust/webui/src/types/swarm.types.ts b/locust/webui/src/types/swarm.types.ts index 4688a1f487..08a4b14add 100644 --- a/locust/webui/src/types/swarm.types.ts +++ b/locust/webui/src/types/swarm.types.ts @@ -10,7 +10,7 @@ import { ISwarmException, } from 'types/ui.types'; -export interface IExtraOptionParameter extends Omit { +export interface IExtraOptionParameter extends Omit { helpText: string | null; } From 4339f8fbb923a7ab8b4335bf42e67803d20fdd84 Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 22 Oct 2024 16:22:43 +0200 Subject: [PATCH 6/7] Add password field test --- .../src/components/Form/PasswordField.tsx | 2 +- .../Form/tests/PasswordField.test.tsx | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 locust/webui/src/components/Form/tests/PasswordField.test.tsx diff --git a/locust/webui/src/components/Form/PasswordField.tsx b/locust/webui/src/components/Form/PasswordField.tsx index 7c1db53753..93f5f3d382 100644 --- a/locust/webui/src/components/Form/PasswordField.tsx +++ b/locust/webui/src/components/Form/PasswordField.tsx @@ -8,7 +8,7 @@ export default function PasswordField({ name = 'password', label = 'Password', defaultValue, -}: Pick) { +}: Partial>) { const [showPassword, setShowPassword] = useState(false); const handleClickShowPassword = () => setShowPassword(!showPassword); diff --git a/locust/webui/src/components/Form/tests/PasswordField.test.tsx b/locust/webui/src/components/Form/tests/PasswordField.test.tsx new file mode 100644 index 0000000000..1d00dc8c20 --- /dev/null +++ b/locust/webui/src/components/Form/tests/PasswordField.test.tsx @@ -0,0 +1,41 @@ +import { act } from 'react'; +import { fireEvent } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import PasswordField from 'components/Form/PasswordField'; +import { renderWithProvider } from 'test/testUtils'; + +describe('SwarmCustomParameters', () => { + test('renders a password field', () => { + const fieldName = 'passwordField'; + const fieldLabel = 'Password Field'; + + const { getByLabelText } = renderWithProvider( + , + ); + + const passwordField = getByLabelText(fieldLabel); + + expect(passwordField).toBeTruthy(); + expect(passwordField.getAttribute('name')).toBe(fieldName); + expect(passwordField.getAttribute('type')).toBe('password'); + }); + + test('displays the password on visibility toggle click', () => { + const fieldName = 'passwordField'; + const fieldLabel = 'Password Field'; + + const { getByRole, getByLabelText } = renderWithProvider( + , + ); + + const visibilityToggle = getByRole('button'); + const passwordField = getByLabelText(fieldLabel); + + act(() => { + fireEvent.click(visibilityToggle); + }); + + expect(passwordField.getAttribute('type')).toBe('text'); + }); +}); From 8f87f0fa59336f3622f56c3fd768cccde58e2d3c Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Tue, 22 Oct 2024 16:32:52 +0200 Subject: [PATCH 7/7] Add numeric field test --- .../src/components/Form/NumericField.tsx | 4 +- .../Form/tests/CustomInput.test.tsx | 2 +- .../Form/tests/NumericField.test.tsx | 76 +++++++++++++++++++ .../Form/tests/PasswordField.test.tsx | 2 +- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 locust/webui/src/components/Form/tests/NumericField.test.tsx diff --git a/locust/webui/src/components/Form/NumericField.tsx b/locust/webui/src/components/Form/NumericField.tsx index 3acf301c7f..b871373676 100644 --- a/locust/webui/src/components/Form/NumericField.tsx +++ b/locust/webui/src/components/Form/NumericField.tsx @@ -20,7 +20,9 @@ export default function NumericField({ defaultValue, ...textFieldProps }: TextFi }; useEffect(() => { - setValue(defaultValue as string); + if (defaultValue) { + setValue(defaultValue as string); + } }, [defaultValue]); return ; diff --git a/locust/webui/src/components/Form/tests/CustomInput.test.tsx b/locust/webui/src/components/Form/tests/CustomInput.test.tsx index a60e11c864..9cbf367464 100644 --- a/locust/webui/src/components/Form/tests/CustomInput.test.tsx +++ b/locust/webui/src/components/Form/tests/CustomInput.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'; import CustomInput from 'components/Form/CustomInput'; import { renderWithProvider } from 'test/testUtils'; -describe('SwarmCustomParameters', () => { +describe('CustomInput', () => { test('renders a text field', () => { const fieldName = 'textField'; const fieldLabel = 'Text Field'; diff --git a/locust/webui/src/components/Form/tests/NumericField.test.tsx b/locust/webui/src/components/Form/tests/NumericField.test.tsx new file mode 100644 index 0000000000..80d6b83fa4 --- /dev/null +++ b/locust/webui/src/components/Form/tests/NumericField.test.tsx @@ -0,0 +1,76 @@ +import { act } from 'react'; +import { fireEvent } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; + +import NumericField from 'components/Form/NumericField'; +import { renderWithProvider } from 'test/testUtils'; + +describe('NumericField', () => { + test('renders a text field', () => { + const fieldName = 'numericField'; + const fieldLabel = 'Numeric Field'; + + const { getByLabelText } = renderWithProvider( + , + ); + + const numericField = getByLabelText(fieldLabel); + + expect(numericField).toBeTruthy(); + expect(numericField.getAttribute('name')).toBe(fieldName); + expect(numericField.getAttribute('type')).toBe('text'); + }); + + test('filters a non-numeric value input', () => { + const fieldName = 'numericField'; + const fieldLabel = 'Numeric Field'; + + const { getByLabelText } = renderWithProvider( + , + ); + + const numericField = getByLabelText(fieldLabel); + + act(() => { + fireEvent.change(numericField, { target: { value: '123hello' } }); + }); + + expect(numericField.getAttribute('value')).toBe('123'); + }); + + test('allows at most one decimal point', () => { + const fieldName = 'numericField'; + const fieldLabel = 'Numeric Field'; + + const { getByLabelText } = renderWithProvider( + , + ); + + const numericField = getByLabelText(fieldLabel); + + act(() => { + fireEvent.change(numericField, { target: { value: '1.23' } }); + fireEvent.change(numericField, { target: { value: '1.23.' } }); + fireEvent.change(numericField, { target: { value: '1.234' } }); + }); + + expect(numericField.getAttribute('value')).toBe('1.234'); + }); + + test('allows a decimal point as the first value', () => { + const fieldName = 'numericField'; + const fieldLabel = 'Numeric Field'; + + const { getByLabelText } = renderWithProvider( + , + ); + + const numericField = getByLabelText(fieldLabel); + + act(() => { + fireEvent.change(numericField, { target: { value: '.23' } }); + }); + + expect(numericField.getAttribute('value')).toBe('.23'); + }); +}); diff --git a/locust/webui/src/components/Form/tests/PasswordField.test.tsx b/locust/webui/src/components/Form/tests/PasswordField.test.tsx index 1d00dc8c20..ab63a88523 100644 --- a/locust/webui/src/components/Form/tests/PasswordField.test.tsx +++ b/locust/webui/src/components/Form/tests/PasswordField.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, test } from 'vitest'; import PasswordField from 'components/Form/PasswordField'; import { renderWithProvider } from 'test/testUtils'; -describe('SwarmCustomParameters', () => { +describe('PasswordField', () => { test('renders a password field', () => { const fieldName = 'passwordField'; const fieldLabel = 'Password Field';