Skip to content

Commit 61a5a4c

Browse files
authored
Merge pull request galaxyproject#21102 from jmchilton/playwright_migrate_2
Add Playwright Backend Support to Galaxy Browser Automation Framework
2 parents be98011 + 277c414 commit 61a5a4c

158 files changed

Lines changed: 8081 additions & 635 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/playwright.yaml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: Playwright tests
2+
on:
3+
push:
4+
paths-ignore:
5+
- 'doc/**'
6+
- 'packages/**'
7+
pull_request:
8+
paths-ignore:
9+
- 'doc/**'
10+
- 'packages/**'
11+
schedule:
12+
# Run at midnight UTC every Tuesday
13+
- cron: '0 0 * * 2'
14+
env:
15+
GALAXY_CONFIG_GALAXY_URL_PREFIX: '/galaxypf'
16+
GALAXY_TEST_DBURI: 'postgresql://postgres:postgres@localhost:5432/galaxy?client_encoding=utf8'
17+
GALAXY_TEST_RAISE_EXCEPTION_ON_HISTORYLESS_HDA: '1'
18+
GALAXY_TEST_SELENIUM_RETRIES: 1
19+
GALAXY_TEST_SKIP_FLAKEY_TESTS_ON_ERROR: 1
20+
GALAXY_TEST_SELENIUM_HEADLESS: 1
21+
YARN_INSTALL_OPTS: --frozen-lockfile
22+
GALAXY_CONFIG_SQLALCHEMY_WARN_20: '1'
23+
concurrency:
24+
group: ${{ github.workflow }}-${{ github.ref }}
25+
cancel-in-progress: true
26+
jobs:
27+
build-client:
28+
uses: ./.github/workflows/build_client.yaml
29+
test:
30+
name: Test
31+
needs: [build-client]
32+
runs-on: ubuntu-latest
33+
strategy:
34+
fail-fast: false
35+
matrix:
36+
python-version: ['3.9']
37+
chunk: [0, 1, 2]
38+
services:
39+
postgres:
40+
image: postgres:17
41+
env:
42+
POSTGRES_USER: postgres
43+
POSTGRES_PASSWORD: postgres
44+
POSTGRES_DB: postgres
45+
ports:
46+
- 5432:5432
47+
steps:
48+
- if: github.event_name == 'schedule'
49+
run: |
50+
echo "GALAXY_CONFIG_OVERRIDE_METADATA_STRATEGY=extended" >> $GITHUB_ENV
51+
echo "GALAXY_CONFIG_OVERRIDE_OUTPUTS_TO_WORKING_DIRECTORY=true" >> $GITHUB_ENV
52+
- uses: actions/checkout@v5
53+
with:
54+
path: 'galaxy root'
55+
persist-credentials: false
56+
- uses: actions/setup-python@v6
57+
with:
58+
python-version: ${{ matrix.python-version }}
59+
cache: 'pip'
60+
cache-dependency-path: 'galaxy root/requirements.txt'
61+
- name: Get full Python version
62+
id: full-python-version
63+
shell: bash
64+
run: echo "version=$(python -c 'import sys; print("-".join(str(v) for v in sys.version_info))')" >> $GITHUB_OUTPUT
65+
- name: Cache galaxy venv
66+
uses: actions/cache@v4
67+
with:
68+
path: 'galaxy root/.venv'
69+
key: gxy-venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('galaxy root/requirements.txt') }}-playwright
70+
- name: Install Playwright
71+
run: |
72+
pip install playwright
73+
playwright install chromium --with-deps
74+
working-directory: 'galaxy root'
75+
- name: Restore client cache
76+
uses: actions/cache@v4
77+
with:
78+
key: galaxy-static-${{ needs.build-client.outputs.commit-id }}
79+
path: 'galaxy root/static'
80+
- name: Run tests
81+
run: ./run_tests.sh --coverage -playwright lib/galaxy_test/selenium -- --num-shards=3 --shard-id=${{ matrix.chunk }}
82+
working-directory: 'galaxy root'
83+
- uses: codecov/codecov-action@v5
84+
with:
85+
flags: playwright
86+
working-directory: 'galaxy root'
87+
- uses: actions/upload-artifact@v4
88+
if: failure()
89+
with:
90+
name: Playwright test results (${{ matrix.python-version }}, ${{ matrix.chunk }})
91+
path: 'galaxy root/run_playwright_tests.html'
92+
- uses: actions/upload-artifact@v4
93+
if: failure()
94+
with:
95+
name: Playwright debug info (${{ matrix.python-version }}, ${{ matrix.chunk }})
96+
path: 'galaxy root/database/test_errors'

client/src/components/User/UserPreferences.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ async function signOut() {
112112
okVariant: "danger",
113113
cancelTitle: "Cancel",
114114
cancelVariant: "outline-primary",
115+
// data-description cannot be set for this so falling back to
116+
// setting a class for DOM inspection.
117+
modalClass: "sign-out-modal",
115118
centered: true,
116119
});
117120

client/src/utils/navigation/navigation.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ change_user_address:
139139

140140
sign_out:
141141
selectors:
142-
cancel_button: '.modal-footer .btn-outline-primary'
143-
sign_out_button: '.modal-footer .btn-danger'
142+
cancel_button: '.sign-out-modal .modal-footer .btn-outline-primary'
143+
sign_out_button: '.sign-out-modal .modal-footer .btn-danger'
144144

145145
dataset_details:
146146
selectors:

lib/galaxy/objectstore/templates/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from typing_extensions import (
1515
Annotated,
1616
Literal,
17+
TypeAlias,
1718
)
1819

1920
from galaxy.objectstore.badges import (
@@ -39,7 +40,7 @@
3940
)
4041

4142
ObjectStoreTemplateVariableType = TemplateVariableType
42-
ObjectStoreTemplateVariableValueType = TemplateVariableValueType
43+
ObjectStoreTemplateVariableValueType: TypeAlias = TemplateVariableValueType
4344
ObjectStoreTemplateType = Literal["aws_s3", "azure_blob", "boto3", "disk", "generic_s3", "onedata", "rucio", "irods"]
4445

4546

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Browser availability checking utilities for Selenium and Playwright.
2+
3+
This module provides functions to check if browsers are available and properly
4+
configured for automated testing. These functions are used for conditional test
5+
skipping and diagnostic scripts.
6+
"""
7+
8+
from functools import lru_cache
9+
10+
from playwright.sync_api import sync_playwright
11+
from selenium import webdriver
12+
from selenium.webdriver.chrome.options import Options as ChromeOptions
13+
14+
# Installation instruction constants
15+
SELENIUM_INSTALL_INSTRUCTIONS = (
16+
"Install Chrome browser and chromedriver. See: https://chromedriver.chromium.org/getting-started"
17+
)
18+
19+
PLAYWRIGHT_INSTALL_INSTRUCTIONS = "Install with: playwright install chromium"
20+
21+
# Skip message constants for pytest
22+
SELENIUM_BROWSER_NOT_AVAILABLE_MESSAGE = f"Selenium Chrome browser not available. {SELENIUM_INSTALL_INSTRUCTIONS}"
23+
24+
PLAYWRIGHT_BROWSER_NOT_AVAILABLE_MESSAGE = (
25+
f"Playwright Chromium browser not available. {PLAYWRIGHT_INSTALL_INSTRUCTIONS}"
26+
)
27+
28+
29+
def is_selenium_browser_available() -> bool:
30+
"""
31+
Check if Selenium WebDriver can launch Chrome browser.
32+
33+
Returns:
34+
bool: True if Chrome browser is available and can be launched, False otherwise
35+
"""
36+
try:
37+
options = ChromeOptions()
38+
options.add_argument("--headless=new")
39+
options.add_argument("--no-sandbox")
40+
options.add_argument("--disable-dev-shm-usage")
41+
driver = webdriver.Chrome(options=options)
42+
driver.quit()
43+
return True
44+
except Exception:
45+
return False
46+
47+
48+
def is_playwright_browser_available() -> bool:
49+
"""
50+
Check if Playwright Chromium browser is installed and available.
51+
52+
Returns:
53+
bool: True if Playwright Chromium is installed, False otherwise
54+
"""
55+
try:
56+
with sync_playwright() as p:
57+
browser = p.chromium.launch(headless=True)
58+
browser.close()
59+
return True
60+
except Exception:
61+
return False
62+
63+
64+
def get_selenium_availability_message() -> str:
65+
"""
66+
Get a descriptive message about Selenium browser availability.
67+
68+
Returns:
69+
str: Status message indicating if Selenium is available or how to enable it
70+
"""
71+
if is_selenium_browser_available():
72+
return "Selenium Chrome browser is available"
73+
else:
74+
return SELENIUM_BROWSER_NOT_AVAILABLE_MESSAGE
75+
76+
77+
def get_playwright_availability_message() -> str:
78+
"""
79+
Get a descriptive message about Playwright browser availability.
80+
81+
Returns:
82+
str: Status message indicating if Playwright is available or how to enable it
83+
"""
84+
if is_playwright_browser_available():
85+
return "Playwright Chromium browser is available"
86+
else:
87+
return PLAYWRIGHT_BROWSER_NOT_AVAILABLE_MESSAGE
88+
89+
90+
@lru_cache(maxsize=1)
91+
def check_selenium_cached() -> bool:
92+
"""
93+
Check Selenium browser availability with caching.
94+
95+
This avoids repeatedly launching browsers during test collection.
96+
Uses lru_cache with maxsize=1 to cache the result of the check.
97+
98+
Returns:
99+
bool: True if Selenium Chrome browser is available
100+
"""
101+
return is_selenium_browser_available()
102+
103+
104+
@lru_cache(maxsize=1)
105+
def check_playwright_cached() -> bool:
106+
"""
107+
Check Playwright browser availability with caching.
108+
109+
This avoids repeatedly launching browsers during test collection.
110+
Uses lru_cache with maxsize=1 to cache the result of the check.
111+
112+
Returns:
113+
bool: True if Playwright Chromium browser is available
114+
"""
115+
return is_playwright_browser_available()
116+
117+
118+
__all__ = (
119+
# Constants
120+
"SELENIUM_INSTALL_INSTRUCTIONS",
121+
"PLAYWRIGHT_INSTALL_INSTRUCTIONS",
122+
"SELENIUM_BROWSER_NOT_AVAILABLE_MESSAGE",
123+
"PLAYWRIGHT_BROWSER_NOT_AVAILABLE_MESSAGE",
124+
# Functions
125+
"is_selenium_browser_available",
126+
"is_playwright_browser_available",
127+
"get_selenium_availability_message",
128+
"get_playwright_availability_message",
129+
"check_selenium_cached",
130+
"check_playwright_cached",
131+
)

lib/galaxy/selenium/cli.py

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@
44
REMOTE_PORT_DESCRIPTION = "Selenium hub remote port to use (if remote driver in use)."
55
GALAXY_URL_DESCRIPTION = "URL of Galaxy instance to target."
66
HEADLESS_DESCRIPTION = "Use local selenium headlessly (native in chrome, otherwise this requires pyvirtualdisplay)."
7+
BACKEND_DESCRIPTION = "Browser automation backend to use (selenium or playwright)."
78

9+
from typing import Literal
810
from urllib.parse import urljoin
911

1012
from .driver_factory import (
11-
get_local_driver,
12-
get_remote_driver,
13+
ConfiguredDriver,
1314
virtual_display_if_enabled,
1415
)
15-
from .navigates_galaxy import NavigatesGalaxy
16+
from .navigates_galaxy import (
17+
galaxy_timeout_handler,
18+
NavigatesGalaxy,
19+
)
1620

1721

1822
def add_selenium_arguments(parser):
@@ -50,29 +54,50 @@ def add_selenium_arguments(parser):
5054
default="http://127.0.0.1:8080/",
5155
help=GALAXY_URL_DESCRIPTION,
5256
)
57+
parser.add_argument(
58+
"--backend",
59+
default="selenium",
60+
choices=["selenium", "playwright"],
61+
help=BACKEND_DESCRIPTION,
62+
)
5363

5464
return parser
5565

5666

5767
class DriverWrapper(NavigatesGalaxy):
58-
"""Adapt argparse command-line options to a concrete Selenium driver."""
68+
"""Adapt argparse command-line options to a browser automation driver."""
5969

6070
def __init__(self, args):
6171
browser = args.selenium_browser
62-
self.display = virtual_display_if_enabled(args.selenium_headless)
63-
if args.selenium_remote:
64-
driver = get_remote_driver(
65-
host=args.selenium_remote_host,
66-
port=args.selenium_remote_port,
67-
browser=browser,
68-
)
69-
else:
70-
driver = get_local_driver(
71-
browser=browser,
72-
)
73-
self.driver = driver
72+
backend_type: Literal["selenium", "playwright"] = args.backend
73+
74+
# Validate remote option (Playwright doesn't support remote)
75+
if backend_type == "playwright" and args.selenium_remote:
76+
raise ValueError("Playwright backend does not support remote drivers")
77+
78+
# Set up virtual display for headless mode (Selenium only)
79+
self.display = None
80+
if backend_type == "selenium":
81+
self.display = virtual_display_if_enabled(args.selenium_headless)
82+
83+
# Create configured driver with the specified backend
84+
# TODO: parameterize timeout multiplier
85+
self.configured_driver = ConfiguredDriver(
86+
galaxy_timeout_handler(1.0),
87+
browser=browser,
88+
remote=args.selenium_remote,
89+
remote_host=args.selenium_remote_host,
90+
remote_port=args.selenium_remote_port,
91+
headless=args.selenium_headless,
92+
backend_type=backend_type,
93+
)
7494
self.target_url = args.galaxy_url
7595

96+
@property
97+
def _driver_impl(self):
98+
"""Provide driver implementation from configured_driver."""
99+
return self.configured_driver.driver_impl
100+
76101
def build_url(self, url="", for_selenium: bool = True):
77102
return urljoin(self.target_url, url)
78103

@@ -87,17 +112,21 @@ def default_timeout(self):
87112
return 15
88113

89114
def finish(self):
115+
"""Clean up driver and display resources."""
90116
exception = None
91117

118+
# Quit the driver (works for both backends via protocol)
92119
try:
93-
self.driver.close()
120+
self.quit()
94121
except Exception as e:
95122
exception = e
96123

97-
try:
98-
self.display.stop()
99-
except Exception as e:
100-
exception = e
124+
# Stop virtual display if used (Selenium only)
125+
if self.display is not None:
126+
try:
127+
self.display.stop()
128+
except Exception as e:
129+
exception = e
101130

102131
if exception is not None:
103132
raise exception

0 commit comments

Comments
 (0)