diff --git a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/02-spawner.py b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/02-spawner.py index 95ce2161c0..2557a497a7 100644 --- a/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/02-spawner.py +++ b/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/jupyterhub/files/jupyterhub/02-spawner.py @@ -73,19 +73,16 @@ def get_conda_store_environments(user_info: dict): # will contain all the environment info returned from the api env_data = [] - # If there are more records than the specified size limit, then - # will need to page through to get all the available envs - if total_records > page_size: - # generate a list of urls to hit to build the response - urls = generate_paged_urls(base_url, total_records, page_size) - - # get content from urls - for url in urls: - response = http.request( - "GET", url, headers={"Authorization": f"Bearer {token}"} - ) - decoded_response = json.loads(response.data.decode("UTF-8")) - env_data += decoded_response.get("data", []) + # generate a list of urls to hit to build the response + urls = generate_paged_urls(base_url, total_records, page_size) + + # get content from urls + for url in urls: + response = http.request( + "GET", url, headers={"Authorization": f"Bearer {token}"} + ) + decoded_response = json.loads(response.data.decode("UTF-8")) + env_data += decoded_response.get("data", []) # Filter and return conda environments for the user return [f"{env['namespace']['name']}-{env['name']}" for env in env_data] diff --git a/tests/common/handlers.py b/tests/common/handlers.py index 761a6efd88..5485059141 100644 --- a/tests/common/handlers.py +++ b/tests/common/handlers.py @@ -86,20 +86,31 @@ def _dismiss_kernel_popup(self): def _shutdown_all_kernels(self): """Shutdown all running kernels.""" logger.debug(">>> Shutting down all kernels") - kernel_menu = self.page.get_by_role("menuitem", name="Kernel") - kernel_menu.click() + + # Open the "Kernel" menu + self.page.get_by_role("menuitem", name="Kernel").click() + + # Locate the "Shut Down All Kernels…" menu item shut_down_all = self.page.get_by_role("menuitem", name="Shut Down All Kernels…") - logger.debug( - f">>> Shut down all kernels visible: {shut_down_all.is_visible()} enabled: {shut_down_all.is_enabled()}" - ) - if shut_down_all.is_visible() and shut_down_all.is_enabled(): - shut_down_all.click() - self.page.get_by_role("button", name="Shut Down All").click() - else: + + # If it's not visible or is disabled, there's nothing to shut down + if not shut_down_all.is_visible() or shut_down_all.is_disabled(): logger.debug(">>> No kernels to shut down") + return + + # Otherwise, click to shut down all kernels and confirm + shut_down_all.click() + self.page.get_by_role("button", name="Shut Down All").click() def _navigate_to_root_folder(self): """Navigate back to the root folder in JupyterLab.""" + # Make sure the home directory is select in the sidebar + if not self.page.get_by_role( + "region", name="File Browser Section" + ).is_visible(): + file_browser_tab = self.page.get_by_role("tab", name="File Browser") + file_browser_tab.click() + logger.debug(">>> Navigating to root folder") self.page.get_by_title(f"/home/{self.nav.username}", exact=True).locator( "path" @@ -303,9 +314,18 @@ def _open_new_environment_tab(self): ).to_be_visible() def _assert_user_namespace(self): - expect( - self.page.get_by_role("button", name=f"{self.nav.username} Create a new") - ).to_be_visible() + user_namespace_dropdown = self.page.get_by_role( + "button", name=f"{self.nav.username} Create a new" + ) + + if not ( + expect( + user_namespace_dropdown + ).to_be_visible() # this asserts the user namespace shows in the UI + or self.nav.username + in user_namespace_dropdown.text_content() # this attests that the namespace corresponds to the logged in user + ): + raise ValueError(f"User namespace {self.nav.username} not found") def _get_shown_namespaces(self): _envs = self.page.locator("#environmentsScroll").get_by_role("button") diff --git a/tests/common/navigator.py b/tests/common/navigator.py index 04e019a7a6..e0b404fd26 100644 --- a/tests/common/navigator.py +++ b/tests/common/navigator.py @@ -5,6 +5,7 @@ from pathlib import Path from playwright.sync_api import expect, sync_playwright +from yarl import URL logger = logging.getLogger() @@ -50,7 +51,7 @@ def setup(self): self.page = self.context.new_page() self.initialized = True - def _rename_test_video_path(self, video_path): + def _rename_test_video_path(self, video_path: Path): """Rename the test video file to the test unique identifier.""" video_file_name = ( f"{self.video_name_prefix}.mp4" if self.video_name_prefix else None @@ -62,7 +63,7 @@ def teardown(self) -> None: """Teardown Playwright browser and context.""" if self.initialized: # Rename the video file to the test unique identifier - current_video_path = self.page.video.path() + current_video_path = Path(self.page.video.path()) self._rename_test_video_path(current_video_path) self.context.close() @@ -87,10 +88,17 @@ class LoginNavigator(NavigatorMixin): def __init__(self, nebari_url, username, password, auth="password", **kwargs): super().__init__(**kwargs) - self.nebari_url = nebari_url + self._nebari_url = URL(nebari_url) self.username = username self.password = password self.auth = auth + logger.debug( + f"LoginNavigator initialized with {self.auth} auth method. :: {self.nebari_url}" + ) + + @property + def nebari_url(self): + return self._nebari_url.human_repr() def login(self): """Login to Nebari deployment using the provided authentication method.""" @@ -110,7 +118,7 @@ def logout(self): def _login_google(self): logger.debug(">>> Sign in via Google and start the server") - self.page.goto(self.nebari_url) + self.page.goto(url=self.nebari_url) expect(self.page).to_have_url(re.compile(f"{self.nebari_url}*")) self.page.get_by_role("button", name="Sign in with Keycloak").click() @@ -123,7 +131,7 @@ def _login_google(self): def _login_password(self): logger.debug(">>> Sign in via Username/Password") - self.page.goto(self.nebari_url) + self.page.goto(url=self.nebari_url) expect(self.page).to_have_url(re.compile(f"{self.nebari_url}*")) self.page.get_by_role("button", name="Sign in with Keycloak").click() diff --git a/tests/common/playwright_fixtures.py b/tests/common/playwright_fixtures.py index 35ea36baad..581d9347f8 100644 --- a/tests/common/playwright_fixtures.py +++ b/tests/common/playwright_fixtures.py @@ -23,17 +23,43 @@ def load_env_vars(): def build_params(request, pytestconfig, extra_params=None): """Construct and return parameters for navigator instances.""" env_vars = load_env_vars() + + # Retrieve values from request or environment + nebari_url = request.param.get("nebari_url") or env_vars.get("nebari_url") + username = request.param.get("keycloak_username") or env_vars.get("username") + password = request.param.get("keycloak_password") or env_vars.get("password") + + # Validate that required fields are present + if not nebari_url: + raise ValueError( + "Error: 'nebari_url' is required but was not provided in " + "'request.param' or environment variables." + ) + if not username: + raise ValueError( + "Error: 'username' is required but was not provided in " + "'request.param' or environment variables." + ) + if not password: + raise ValueError( + "Error: 'password' is required but was not provided in " + "'request.param' or environment variables." + ) + + # Build the params dictionary once all required fields are validated params = { - "nebari_url": request.param.get("nebari_url") or env_vars["nebari_url"], - "username": request.param.get("keycloak_username") or env_vars["username"], - "password": request.param.get("keycloak_password") or env_vars["password"], + "nebari_url": nebari_url, + "username": username, + "password": password, "auth": "password", "video_dir": "videos/", "headless": pytestconfig.getoption("--headed"), "slow_mo": pytestconfig.getoption("--slowmo"), } + if extra_params: params.update(extra_params) + return params diff --git a/tests/tests_e2e/playwright/.env.tpl b/tests/tests_e2e/playwright/.env.tpl index 399eff80c7..d1fad0a084 100644 --- a/tests/tests_e2e/playwright/.env.tpl +++ b/tests/tests_e2e/playwright/.env.tpl @@ -1,3 +1,3 @@ KEYCLOAK_USERNAME="USERNAME_OR_GOOGLE_EMAIL" KEYCLOAK_PASSWORD="PASSWORD" -NEBARI_FULL_URL="https://nebari.quansight.dev/" +NEBARI_FULL_URL="https://localhost/" diff --git a/tests/tests_e2e/playwright/Makefile b/tests/tests_e2e/playwright/Makefile new file mode 100644 index 0000000000..429a8a4ac5 --- /dev/null +++ b/tests/tests_e2e/playwright/Makefile @@ -0,0 +1,10 @@ +.PHONY: setup + +setup: + @echo "Setting up correct pins for playwright user-journey tests" + pip install -r requirements.txt + @echo "Setting up playwright browser dependencies" + playwright install + @echo "Setting up .env file" + cp .env.tpl .env + @echo "Please fill in the .env file with the correct values" diff --git a/tests/tests_e2e/playwright/README.md b/tests/tests_e2e/playwright/README.md index c328681273..bb3592c9b2 100644 --- a/tests/tests_e2e/playwright/README.md +++ b/tests/tests_e2e/playwright/README.md @@ -33,48 +33,57 @@ tests - `handlers.py`: Contains classes fore handling the different level of access to services a User might encounter, such as Notebook, Conda-store and others. -## Setup - -1. **Install Nebari with Development Requirements** +Below is an example of how you might update the **Setup** and **Running the Playwright Tests** sections of your README to reflect the new `Makefile` and the updated `pytest` invocation. - Install Nebari including development requirements (which include Playwright): +--- - ```bash - pip install -e ".[dev]" - ``` +## Setup -2. **Install Playwright** +1. **Use the provided Makefile to install dependencies** - Install Playwright: + Navigate to the Playwright tests directory and run the `setup` target: ```bash - playwright install + cd tests_e2e/playwright + make setup ``` - *Note:* If you see the warning `BEWARE: your OS is not officially supported by Playwright; downloading fallback build`, it is not critical. Playwright should still work (see microsoft/playwright#15124). + This command will: -3. **Create Environment Vars** + - Install the pinned dependencies from `requirements.txt`. + - Install Playwright and its required browser dependencies. + - Create a new `.env` file from `.env.tpl`. - Fill in your execution space environment with the following values: +2. **Fill in the `.env` file** - - `KEYCLOAK_USERNAME`: Nebari username for username/password login or Google email address/Google sign-in. - - `KEYCLOAK_PASSWORD`: Password associated with `KEYCLOAK_USERNAME`. - - `NEBARI_FULL_URL`: Full URL path including scheme to the Nebari instance (e.g., "https://nebari.quansight.dev/"). + Open the newly created `.env` file and fill in the following values: - This user can be created with the following command (or use an existing non-root user): + - `KEYCLOAK_USERNAME`: Nebari username for username/password login (or Google email for Google sign-in). + - `KEYCLOAK_PASSWORD`: Password associated with the above username. + - `NEBARI_FULL_URL`: Full URL (including `https://`) to the Nebari instance (e.g., `https://nebari.quansight.dev/`). + + If you need to create a user for testing, you can do so with: ```bash nebari keycloak adduser --user --config ``` -## Running the Playwright Tests +*Note:* If you see the warning: +``` +BEWARE: your OS is not officially supported by Playwright; downloading fallback build +``` +it is not critical. Playwright should still work despite the warning. -Playwright tests are run inside of pytest using: +## Running the Playwright Tests +You can run the Playwright tests with `pytest`. ```bash -pytest tests_e2e/playwright/test_playwright.py +pytest tests_e2e/playwright/test_playwright.py --numprocesses auto ``` +> **Important**: Due to how Pytest manages async code; Playwright’s sync calls can conflict with default Pytest concurrency settings, and using `--numprocesses auto` helps mitigate potential thread-blocking issues. + + Videos of the test playback will be available in `$PWD/videos/`. To disabled the browser runtime preview of what is happening while the test runs, pass the `--headed` option to `pytest`. You can also add the `--slowmo=$MILLI_SECONDS` option to introduce a delay before each @@ -188,3 +197,17 @@ If your test suit presents a need for a more complex sequence of actions or spec parsing around the contents present in each page, you can create your own handler to execute the auxiliary actions while the test is running. Check the `handlers.py` over some examples of how that's being done. + + +## Debugging Playwright tests + +Playwright supports a debug mode called +[Inspector](https://playwright.dev/python/docs/debug#playwright-inspector) that can be +used to inspect the browser and the page while the test is running. To enabled this +debugging option within the tests execution you can pass the `PWDEBUG=1` variable within +your test execution command. + +For example, to run a single test with the debug mode enabled, you can use the following +```bash +PWDEBUG=1 pytest -s test_playwright.py::test_notebook --numprocesses 1 +``` diff --git a/tests/tests_e2e/playwright/requirements.txt b/tests/tests_e2e/playwright/requirements.txt new file mode 100644 index 0000000000..0e5093a62d --- /dev/null +++ b/tests/tests_e2e/playwright/requirements.txt @@ -0,0 +1,4 @@ +playwright==1.50.0 +pytest==8.0.0 +pytest-playwright==0.7.0 +pytest-xdist==3.6.1 diff --git a/tests/tests_e2e/playwright/test_playwright.py b/tests/tests_e2e/playwright/test_playwright.py index 9d04a4e027..0a835c8413 100644 --- a/tests/tests_e2e/playwright/test_playwright.py +++ b/tests/tests_e2e/playwright/test_playwright.py @@ -30,7 +30,8 @@ def test_login_logout(navigator): ) @login_parameterized() def test_navbar_services(navigator, services): - navigator.page.goto(navigator.nebari_url + "hub/home") + home_url = navigator._nebari_url / "hub/home" + navigator.page.goto(home_url.human_repr()) navigator.page.wait_for_load_state("networkidle") navbar_items = navigator.page.locator("#thenavbar").get_by_role("link") navbar_items_names = [item.text_content() for item in navbar_items.all()]