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

Refactor and extend Playwright tests #2644

Merged
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 additions & 2 deletions .github/workflows/test_local_integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
run:
shell: bash -l {0}
steps:
- name: 'Checkout Infrastructure'
- name: "Checkout Infrastructure"
uses: actions/checkout@main
with:
fetch-depth: 0
Expand Down Expand Up @@ -172,7 +172,7 @@ jobs:
# create environment file
envsubst < .env.tpl > .env
# run playwright pytest tests in headed mode with the chromium browser
xvfb-run pytest --browser chromium
xvfb-run pytest --browser chromium --slowmo 300 --headed
Copy link
Member

Choose a reason for hiding this comment

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

@viniciusdc is there any downside of having the --slowmo flag here? Based on your commit messages, I'm guessing you added it to allow some time for the notebook to load.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The only downside would be that all related playwright actions within a test (click a button, query, refresh etc) will take a slight increase in execution time which in return increases the time for the overall playwright pytest suit to complete -- though, I advice that this is essential for some components to load properly

Copy link
Member

Choose a reason for hiding this comment

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

I'm guessing the increase in execution time won't really be a significant issue. I'm more concerned with whether the appropriate way of dealing with components loading would be to use the wait_for_load_state (or similar) method directly within the playwright page.

I'll let you decide what's best and I don't think we need to implement everything in this PR anyways. Thanks Vini!


- name: Save Cypress screenshots and videos
if: always()
Expand Down
342 changes: 342 additions & 0 deletions tests/common/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,342 @@
import logging
import re
import time

from playwright.sync_api import expect

logger = logging.getLogger()


class JupyterLab:
def __init__(self, navigator):
logger.debug(">>> Starting notebook manager...")
self.nav = navigator
self.page = self.nav.page

def reset_workspace(self):
"""Reset the JupyterLab workspace."""
logger.debug(">>> Resetting JupyterLab workspace")

# Check for and handle kernel popup
logger.debug(">>> Checking for kernel popup")
if self._check_for_kernel_popup():
self._handle_kernel_popup()

# Shutdown all kernels
logger.debug(">>> Shutting down all kernels")
self._shutdown_all_kernels()

# Navigate back to root folder and close all tabs
logger.debug(">>> Navigating to root folder and closing all tabs")
self._navigate_to_root_folder()
logger.debug(">>> Closing all tabs")
self._close_all_tabs()

# Ensure theme and launcher screen
logger.debug(">>> Ensuring theme and launcher screen")
self._assert_theme_and_launcher()

def set_environment(self, kernel):
"""Set environment for a Jupyter notebook."""
if not self._check_for_kernel_popup():
self._trigger_kernel_change_popup()

self._handle_kernel_popup(kernel)
self._wait_for_kernel_label(kernel)

def write_file(self, filepath, content):
"""Write a file to the Nebari instance filesystem."""
logger.debug(f">>> Writing file to {filepath}")
self._open_terminal()
self._execute_terminal_commands(
[f"cat <<EOF >{filepath}", content, "EOF", f"ls {filepath}"]
)
time.sleep(2)

def _check_for_kernel_popup(self):
"""Check if the kernel popup is open."""
logger.debug(">>> Checking for kernel popup")
self.page.wait_for_load_state()
time.sleep(3)
visible = self.page.get_by_text("Select KernelStart a new").is_visible()
logger.debug(f">>> Kernel popup visible: {visible}")
return visible

def _handle_kernel_popup(self, kernel=None):
"""Handle kernel popup by selecting the appropriate kernel or dismissing the popup."""
if kernel:
self._select_kernel(kernel)
else:
self._dismiss_kernel_popup()

def _dismiss_kernel_popup(self):
"""Dismiss the kernel selection popup."""
logger.debug(">>> Dismissing kernel popup")
no_kernel_button = self.page.get_by_role("dialog").get_by_role(
"button", name="No Kernel"
)
if no_kernel_button.is_visible():
no_kernel_button.click()
else:
try:
self.page.get_by_role("button", name="Cancel").click()
except Exception:
raise ValueError("Unable to escape kernel selection dialog.")

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()
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:
logger.debug(">>> No kernels to shut down")

def _navigate_to_root_folder(self):
"""Navigate back to the root folder in JupyterLab."""
logger.debug(">>> Navigating to root folder")
self.page.get_by_title(f"/home/{self.nav.username}", exact=True).locator(
"path"
).click()

def _close_all_tabs(self):
"""Close all open tabs in JupyterLab."""
logger.debug(">>> Closing all tabs")
self.page.get_by_text("File", exact=True).click()
self.page.get_by_role("menuitem", name="Close All Tabs", exact=True).click()

if self.page.get_by_text("Save your work", exact=True).is_visible():
self.page.get_by_role(
"button", name="Discard changes to file", exact=True
).click()

def _assert_theme_and_launcher(self):
"""Ensure that the theme is set to JupyterLab Dark and Launcher screen is visible."""
expect(
self.page.get_by_text(
"Set Preferred Dark Theme: JupyterLab Dark", exact=True
)
).to_be_hidden()
self.page.get_by_title("VS Code [↗]").wait_for(state="visible")

def _open_terminal(self):
"""Open a new terminal in JupyterLab."""
self.page.get_by_text("File", exact=True).click()
self.page.get_by_text("New", exact=True).click()
self.page.get_by_role("menuitem", name="Terminal").get_by_text(
"Terminal"
).click()

def _execute_terminal_commands(self, commands):
"""Execute a series of commands in the terminal."""
for command in commands:
self.page.get_by_role("textbox", name="Terminal input").fill(command)
self.page.get_by_role("textbox", name="Terminal input").press("Enter")
time.sleep(0.5)


class Notebook(JupyterLab):
def __init__(self, navigator):
logger.debug(">>> Starting notebook manager...")
self.nav = navigator
self.page = self.nav.page

def _open_notebook(self, notebook_name):
"""Open a notebook in JupyterLab."""
self.page.get_by_text("File", exact=True).click()
self.page.locator("#jp-mainmenu-file").get_by_text("Open from Path…").click()

expect(self.page.get_by_text("Open PathPathCancelOpen")).to_be_visible()

# Fill notebook name into the textbox and click Open
self.page.get_by_placeholder("/path/relative/to/jlab/root").fill(notebook_name)
self.page.get_by_role("button", name="Open").click()
if self.page.get_by_text("Could not find path:").is_visible():
self.page.get_by_role("button", name="Dismiss").click()
raise ValueError(f"Notebook {notebook_name} not found")

# make sure that this notebook is one currently selected
expect(self.page.get_by_role("tab", name=notebook_name)).to_be_visible()

def _run_all_cells(self):
"""Run all cells in a Jupyter notebook."""
self.page.get_by_role("menuitem", name="Run").click()
run_all_cells = self.page.locator("#jp-mainmenu-run").get_by_text(
"Run All Cells", exact=True
)
if run_all_cells.is_visible():
run_all_cells.click()
else:
self.page.get_by_text("Restart the kernel and run").click()
# Check if restart popup is visible
restart_popup = self.page.get_by_text("Restart Kernel?")
if restart_popup.is_visible():
restart_popup.click()
self.page.get_by_role("button", name="Confirm Kernel Restart").click()

def _wait_for_commands_completion(
self, timeout: float, completion_wait_time: float
):
"""
Wait for commands to finish running

Parameters
----------
timeout: float
Time in seconds to wait for the expected output text to appear.
completion_wait_time: float
Time in seconds to wait between checking for expected output text.
"""
elapsed_time = 0.0
still_visible = True
start_time = time.time()
while elapsed_time < timeout:
running = self.nav.page.get_by_text("[*]").all()
still_visible = any(list(map(lambda r: r.is_visible(), running)))
if not still_visible:
break
elapsed_time = time.time() - start_time
time.sleep(completion_wait_time)
if still_visible:
raise ValueError(
f"Timeout Waited for commands to finish, "
f"but couldn't finish in {timeout} sec"
)

def _get_outputs(self):
output_elements = self.nav.page.query_selector_all(".jp-OutputArea-output")
text_content = [element.text_content().strip() for element in output_elements]
return text_content

def run_notebook(self, notebook_name, kernel):
"""Run a notebook in JupyterLab."""
# Open the notebook
logger.debug(f">>> Opening notebook: {notebook_name}")
self._open_notebook(notebook_name)

# Set environment
logger.debug(f">>> Setting environment for kernel: {kernel}")
self.set_environment(kernel=kernel)

# Run all cells
logger.debug(">>> Running all cells")
self._run_all_cells()

# Wait for commands to finish running
logger.debug(">>> Waiting for commands to finish running")
self._wait_for_commands_completion(timeout=300, completion_wait_time=5)

# Get the outputs
logger.debug(">>> Gathering outputs")
outputs = self._get_outputs()

return outputs

def _trigger_kernel_change_popup(self):
"""Trigger the kernel change popup. (expects a notebook to be open)"""
self.page.get_by_role("menuitem", name="Kernel").click()
kernel_menu = self.page.get_by_role("menuitem", name="Change Kernel…")
if kernel_menu.is_visible():
kernel_menu.click()
self.page.get_by_text("Select KernelStart a new").wait_for(state="visible")
logger.debug(">>> Kernel popup is visible")
else:
pass

def _select_kernel(self, kernel):
"""Select a kernel from the popup."""
logger.debug(f">>> Selecting kernel: {kernel}")

self.page.get_by_role("dialog").get_by_label("", exact=True).fill(kernel)

# List of potential selectors
selectors = [
self.page.get_by_role("cell", name=re.compile(kernel, re.IGNORECASE)).nth(
1
),
self.page.get_by_role("cell", name=re.compile(kernel, re.IGNORECASE)).first,
self.page.get_by_text(kernel, exact=True).nth(1),
]

# Try each selector until one is visible and clickable
# this is done due to the different ways the kernel can be displayed
# as part of the new extension
for selector in selectors:
if selector.is_visible():
selector.click()
logger.debug(f">>> Kernel {kernel} selected")
return

# If none of the selectors match, dismiss the popup and raise an error
self._dismiss_kernel_popup()
raise ValueError(f"Kernel {kernel} not found in the list of kernels")

def _wait_for_kernel_label(self, kernel):
"""Wait for the kernel label to be visible."""
kernel_label_loc = self.page.get_by_role("button", name=kernel)
if not kernel_label_loc.is_visible():
kernel_label_loc.wait_for(state="attached")
logger.debug(f">>> Kernel label {kernel} is now visible")


class CondaStore(JupyterLab):
def __init__(self, navigator):
self.page = navigator.page
self.nav = navigator

def _open_conda_store_service(self):
self.page.get_by_text("Services", exact=True).click()
self.page.get_by_text("Environment Management").click()
expect(self.page.get_by_role("tab", name="conda-store")).to_be_visible()
time.sleep(2)

def _open_new_environment_tab(self):
self.page.get_by_label("Create a new environment in").click()
expect(self.page.get_by_text("Create Environment")).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()

def _get_shown_namespaces(self):
_envs = self.page.locator("#environmentsScroll").get_by_role("button")
_env_contents = [env.text_content() for env in _envs.all()]
# Remove the "New" entry from each namespace "button" text
return [
namespace.replace(" New", "")
for namespace in _env_contents
if namespace != " New"
]

def _assert_logged_in(self):
login_button = self.page.get_by_role("button", name="Log in")
if login_button.is_visible():
login_button.click()
# wait for page to reload
self.page.wait_for_load_state()
time.sleep(2)
# A reload is required as conda-store "created" a new page once logged in
self.page.reload()
self.page.wait_for_load_state()
self._open_conda_store_service()
else:
# In this case logout should already be visible
expect(self.page.get_by_role("button", name="Logout")).to_be_visible()
self._assert_user_namespace()

def conda_store_ui(self):
logger.debug(">>> Opening Conda Store UI")
self._open_conda_store_service()

logger.debug(">>> Assert user is logged in")
self._assert_logged_in()

logger.debug(">>> Opening new environment tab")
self._open_new_environment_tab()
Loading
Loading