diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml new file mode 100644 index 000000000000..54e43bbcdccb --- /dev/null +++ b/.github/workflows/playwright.yaml @@ -0,0 +1,96 @@ +name: Playwright tests +on: + push: + paths-ignore: + - 'doc/**' + - 'packages/**' + pull_request: + paths-ignore: + - 'doc/**' + - 'packages/**' + schedule: + # Run at midnight UTC every Tuesday + - cron: '0 0 * * 2' +env: + GALAXY_CONFIG_GALAXY_URL_PREFIX: '/galaxypf' + GALAXY_TEST_DBURI: 'postgresql://postgres:postgres@localhost:5432/galaxy?client_encoding=utf8' + GALAXY_TEST_RAISE_EXCEPTION_ON_HISTORYLESS_HDA: '1' + GALAXY_TEST_SELENIUM_RETRIES: 1 + GALAXY_TEST_SKIP_FLAKEY_TESTS_ON_ERROR: 1 + GALAXY_TEST_SELENIUM_HEADLESS: 1 + YARN_INSTALL_OPTS: --frozen-lockfile + GALAXY_CONFIG_SQLALCHEMY_WARN_20: '1' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + build-client: + uses: ./.github/workflows/build_client.yaml + test: + name: Test + needs: [build-client] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.9'] + chunk: [0, 1, 2] + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + steps: + - if: github.event_name == 'schedule' + run: | + echo "GALAXY_CONFIG_OVERRIDE_METADATA_STRATEGY=extended" >> $GITHUB_ENV + echo "GALAXY_CONFIG_OVERRIDE_OUTPUTS_TO_WORKING_DIRECTORY=true" >> $GITHUB_ENV + - uses: actions/checkout@v5 + with: + path: 'galaxy root' + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'galaxy root/requirements.txt' + - name: Get full Python version + id: full-python-version + shell: bash + run: echo "version=$(python -c 'import sys; print("-".join(str(v) for v in sys.version_info))')" >> $GITHUB_OUTPUT + - name: Cache galaxy venv + uses: actions/cache@v4 + with: + path: 'galaxy root/.venv' + key: gxy-venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('galaxy root/requirements.txt') }}-playwright + - name: Install Playwright + run: | + pip install playwright + playwright install chromium --with-deps + working-directory: 'galaxy root' + - name: Restore client cache + uses: actions/cache@v4 + with: + key: galaxy-static-${{ needs.build-client.outputs.commit-id }} + path: 'galaxy root/static' + - name: Run tests + run: ./run_tests.sh --coverage -playwright lib/galaxy_test/selenium -- --num-shards=3 --shard-id=${{ matrix.chunk }} + working-directory: 'galaxy root' + - uses: codecov/codecov-action@v5 + with: + flags: playwright + working-directory: 'galaxy root' + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: Playwright test results (${{ matrix.python-version }}, ${{ matrix.chunk }}) + path: 'galaxy root/run_playwright_tests.html' + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: Playwright debug info (${{ matrix.python-version }}, ${{ matrix.chunk }}) + path: 'galaxy root/database/test_errors' diff --git a/client/src/components/User/UserPreferences.vue b/client/src/components/User/UserPreferences.vue index 3e7fd096a879..ede9aa55c77d 100644 --- a/client/src/components/User/UserPreferences.vue +++ b/client/src/components/User/UserPreferences.vue @@ -112,6 +112,9 @@ async function signOut() { okVariant: "danger", cancelTitle: "Cancel", cancelVariant: "outline-primary", + // data-description cannot be set for this so falling back to + // setting a class for DOM inspection. + modalClass: "sign-out-modal", centered: true, }); diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml index f0721c0b4960..38f8a505d5c3 100644 --- a/client/src/utils/navigation/navigation.yml +++ b/client/src/utils/navigation/navigation.yml @@ -139,8 +139,8 @@ change_user_address: sign_out: selectors: - cancel_button: '.modal-footer .btn-outline-primary' - sign_out_button: '.modal-footer .btn-danger' + cancel_button: '.sign-out-modal .modal-footer .btn-outline-primary' + sign_out_button: '.sign-out-modal .modal-footer .btn-danger' dataset_details: selectors: diff --git a/lib/galaxy/objectstore/templates/models.py b/lib/galaxy/objectstore/templates/models.py index 8f625d7973cc..b6aefcda1e22 100644 --- a/lib/galaxy/objectstore/templates/models.py +++ b/lib/galaxy/objectstore/templates/models.py @@ -14,6 +14,7 @@ from typing_extensions import ( Annotated, Literal, + TypeAlias, ) from galaxy.objectstore.badges import ( @@ -39,7 +40,7 @@ ) ObjectStoreTemplateVariableType = TemplateVariableType -ObjectStoreTemplateVariableValueType = TemplateVariableValueType +ObjectStoreTemplateVariableValueType: TypeAlias = TemplateVariableValueType ObjectStoreTemplateType = Literal["aws_s3", "azure_blob", "boto3", "disk", "generic_s3", "onedata", "rucio", "irods"] diff --git a/lib/galaxy/selenium/availability.py b/lib/galaxy/selenium/availability.py new file mode 100644 index 000000000000..78d9262bfe8b --- /dev/null +++ b/lib/galaxy/selenium/availability.py @@ -0,0 +1,131 @@ +"""Browser availability checking utilities for Selenium and Playwright. + +This module provides functions to check if browsers are available and properly +configured for automated testing. These functions are used for conditional test +skipping and diagnostic scripts. +""" + +from functools import lru_cache + +from playwright.sync_api import sync_playwright +from selenium import webdriver +from selenium.webdriver.chrome.options import Options as ChromeOptions + +# Installation instruction constants +SELENIUM_INSTALL_INSTRUCTIONS = ( + "Install Chrome browser and chromedriver. See: https://chromedriver.chromium.org/getting-started" +) + +PLAYWRIGHT_INSTALL_INSTRUCTIONS = "Install with: playwright install chromium" + +# Skip message constants for pytest +SELENIUM_BROWSER_NOT_AVAILABLE_MESSAGE = f"Selenium Chrome browser not available. {SELENIUM_INSTALL_INSTRUCTIONS}" + +PLAYWRIGHT_BROWSER_NOT_AVAILABLE_MESSAGE = ( + f"Playwright Chromium browser not available. {PLAYWRIGHT_INSTALL_INSTRUCTIONS}" +) + + +def is_selenium_browser_available() -> bool: + """ + Check if Selenium WebDriver can launch Chrome browser. + + Returns: + bool: True if Chrome browser is available and can be launched, False otherwise + """ + try: + options = ChromeOptions() + options.add_argument("--headless=new") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + driver = webdriver.Chrome(options=options) + driver.quit() + return True + except Exception: + return False + + +def is_playwright_browser_available() -> bool: + """ + Check if Playwright Chromium browser is installed and available. + + Returns: + bool: True if Playwright Chromium is installed, False otherwise + """ + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + browser.close() + return True + except Exception: + return False + + +def get_selenium_availability_message() -> str: + """ + Get a descriptive message about Selenium browser availability. + + Returns: + str: Status message indicating if Selenium is available or how to enable it + """ + if is_selenium_browser_available(): + return "Selenium Chrome browser is available" + else: + return SELENIUM_BROWSER_NOT_AVAILABLE_MESSAGE + + +def get_playwright_availability_message() -> str: + """ + Get a descriptive message about Playwright browser availability. + + Returns: + str: Status message indicating if Playwright is available or how to enable it + """ + if is_playwright_browser_available(): + return "Playwright Chromium browser is available" + else: + return PLAYWRIGHT_BROWSER_NOT_AVAILABLE_MESSAGE + + +@lru_cache(maxsize=1) +def check_selenium_cached() -> bool: + """ + Check Selenium browser availability with caching. + + This avoids repeatedly launching browsers during test collection. + Uses lru_cache with maxsize=1 to cache the result of the check. + + Returns: + bool: True if Selenium Chrome browser is available + """ + return is_selenium_browser_available() + + +@lru_cache(maxsize=1) +def check_playwright_cached() -> bool: + """ + Check Playwright browser availability with caching. + + This avoids repeatedly launching browsers during test collection. + Uses lru_cache with maxsize=1 to cache the result of the check. + + Returns: + bool: True if Playwright Chromium browser is available + """ + return is_playwright_browser_available() + + +__all__ = ( + # Constants + "SELENIUM_INSTALL_INSTRUCTIONS", + "PLAYWRIGHT_INSTALL_INSTRUCTIONS", + "SELENIUM_BROWSER_NOT_AVAILABLE_MESSAGE", + "PLAYWRIGHT_BROWSER_NOT_AVAILABLE_MESSAGE", + # Functions + "is_selenium_browser_available", + "is_playwright_browser_available", + "get_selenium_availability_message", + "get_playwright_availability_message", + "check_selenium_cached", + "check_playwright_cached", +) diff --git a/lib/galaxy/selenium/cli.py b/lib/galaxy/selenium/cli.py index 109343ea5961..04feb67de292 100644 --- a/lib/galaxy/selenium/cli.py +++ b/lib/galaxy/selenium/cli.py @@ -4,15 +4,19 @@ REMOTE_PORT_DESCRIPTION = "Selenium hub remote port to use (if remote driver in use)." GALAXY_URL_DESCRIPTION = "URL of Galaxy instance to target." HEADLESS_DESCRIPTION = "Use local selenium headlessly (native in chrome, otherwise this requires pyvirtualdisplay)." +BACKEND_DESCRIPTION = "Browser automation backend to use (selenium or playwright)." +from typing import Literal from urllib.parse import urljoin from .driver_factory import ( - get_local_driver, - get_remote_driver, + ConfiguredDriver, virtual_display_if_enabled, ) -from .navigates_galaxy import NavigatesGalaxy +from .navigates_galaxy import ( + galaxy_timeout_handler, + NavigatesGalaxy, +) def add_selenium_arguments(parser): @@ -50,29 +54,50 @@ def add_selenium_arguments(parser): default="http://127.0.0.1:8080/", help=GALAXY_URL_DESCRIPTION, ) + parser.add_argument( + "--backend", + default="selenium", + choices=["selenium", "playwright"], + help=BACKEND_DESCRIPTION, + ) return parser class DriverWrapper(NavigatesGalaxy): - """Adapt argparse command-line options to a concrete Selenium driver.""" + """Adapt argparse command-line options to a browser automation driver.""" def __init__(self, args): browser = args.selenium_browser - self.display = virtual_display_if_enabled(args.selenium_headless) - if args.selenium_remote: - driver = get_remote_driver( - host=args.selenium_remote_host, - port=args.selenium_remote_port, - browser=browser, - ) - else: - driver = get_local_driver( - browser=browser, - ) - self.driver = driver + backend_type: Literal["selenium", "playwright"] = args.backend + + # Validate remote option (Playwright doesn't support remote) + if backend_type == "playwright" and args.selenium_remote: + raise ValueError("Playwright backend does not support remote drivers") + + # Set up virtual display for headless mode (Selenium only) + self.display = None + if backend_type == "selenium": + self.display = virtual_display_if_enabled(args.selenium_headless) + + # Create configured driver with the specified backend + # TODO: parameterize timeout multiplier + self.configured_driver = ConfiguredDriver( + galaxy_timeout_handler(1.0), + browser=browser, + remote=args.selenium_remote, + remote_host=args.selenium_remote_host, + remote_port=args.selenium_remote_port, + headless=args.selenium_headless, + backend_type=backend_type, + ) self.target_url = args.galaxy_url + @property + def _driver_impl(self): + """Provide driver implementation from configured_driver.""" + return self.configured_driver.driver_impl + def build_url(self, url="", for_selenium: bool = True): return urljoin(self.target_url, url) @@ -87,17 +112,21 @@ def default_timeout(self): return 15 def finish(self): + """Clean up driver and display resources.""" exception = None + # Quit the driver (works for both backends via protocol) try: - self.driver.close() + self.quit() except Exception as e: exception = e - try: - self.display.stop() - except Exception as e: - exception = e + # Stop virtual display if used (Selenium only) + if self.display is not None: + try: + self.display.stop() + except Exception as e: + exception = e if exception is not None: raise exception diff --git a/lib/galaxy/selenium/context.py b/lib/galaxy/selenium/context.py index e71b8fda9755..b6996fa160fd 100644 --- a/lib/galaxy/selenium/context.py +++ b/lib/galaxy/selenium/context.py @@ -1,9 +1,6 @@ import os from abc import abstractmethod -from typing import ( - Optional, - TYPE_CHECKING, -) +from typing import Optional from urllib.parse import urljoin import yaml @@ -11,15 +8,22 @@ from .driver_factory import ConfiguredDriver from .navigates_galaxy import NavigatesGalaxy -if TYPE_CHECKING: - from selenium.webdriver.remote.webdriver import WebDriver - class GalaxySeleniumContext(NavigatesGalaxy): url: str target_url_from_selenium: str configured_driver: ConfiguredDriver + @property + def _driver_impl(self): + """Provide driver implementation from configured_driver. + + This property bridges the HasDriverProxy mixin to the ConfiguredDriver + used in the test framework. It allows NavigatesGalaxy methods to work + without requiring constructor changes in the test infrastructure. + """ + return self.configured_driver.driver_impl + def build_url(self, url: str, for_selenium: bool = True) -> str: if for_selenium: base = self.target_url_from_selenium @@ -27,10 +31,6 @@ def build_url(self, url: str, for_selenium: bool = True) -> str: base = self.url return urljoin(base, url) - @property - def driver(self) -> "WebDriver": # type: ignore[override] - return self.configured_driver.driver - def screenshot(self, label: str): """If GALAXY_TEST_SCREENSHOTS_DIRECTORY is set create a screenshot there named