diff --git a/.github/workflows/toolshed.yaml b/.github/workflows/toolshed.yaml
index acb57d3471c4..0a707050707a 100644
--- a/.github/workflows/toolshed.yaml
+++ b/.github/workflows/toolshed.yaml
@@ -23,6 +23,8 @@ jobs:
python-version: ['3.7']
test-install-client: ['standalone', 'galaxy_api']
shed-api: ['v1', 'v2']
+ # shed-browser: ['twill', 'playwright']
+ shed-browser: ['twill']
services:
postgres:
image: postgres:13
@@ -59,6 +61,7 @@ jobs:
env:
TOOL_SHED_TEST_INSTALL_CLIENT: ${{ matrix.test-install-client }}
TOOL_SHED_API_VERSION: ${{ matrix.shed-api }}
+ TOOL_SHED_TEST_BROWSER: ${{ matrix.shed-browser }}
working-directory: 'galaxy_root'
- uses: actions/upload-artifact@v3
if: failure()
diff --git a/lib/galaxy/dependencies/dev-requirements.txt b/lib/galaxy/dependencies/dev-requirements.txt
index 2d83fdd2c9fc..80c8b3c69ac9 100644
--- a/lib/galaxy/dependencies/dev-requirements.txt
+++ b/lib/galaxy/dependencies/dev-requirements.txt
@@ -87,6 +87,7 @@ pytest-html==3.2.0 ; python_version >= "3.7" and python_version < "3.11"
pytest-json-report==1.5.0 ; python_version >= "3.7" and python_version < "3.11"
pytest-metadata==2.0.4 ; python_version >= "3.7" and python_version < "3.11"
pytest-mock==3.10.0 ; python_version >= "3.7" and python_version < "3.11"
+pytest-playwright==0.3.0 ; python_version >= "3.7" and python_version < "3.11"
pytest-postgresql==4.1.1 ; python_version >= "3.7" and python_version < "3.11"
pytest-shard==0.1.2 ; python_version >= "3.7" and python_version < "3.11"
pytest==7.2.0 ; python_version >= "3.7" and python_version < "3.11"
diff --git a/lib/tool_shed/test/base/playwrightbrowser.py b/lib/tool_shed/test/base/playwrightbrowser.py
new file mode 100644
index 000000000000..40e52bf251fd
--- /dev/null
+++ b/lib/tool_shed/test/base/playwrightbrowser.py
@@ -0,0 +1,145 @@
+from typing import List
+
+from playwright.sync_api import (
+ expect,
+ Locator,
+ Page,
+)
+
+from .browser import ShedBrowser
+
+
+class PlaywrightShedBrowser(ShedBrowser):
+ _page: Page
+
+ def __init__(self, page: Page):
+ self._page = page
+
+ def visit_url(self, url: str, allowed_codes: List[int]) -> str:
+ response = self._page.goto(url)
+ assert response is not None
+ return_code = response.status
+ assert return_code in allowed_codes, "Invalid HTTP return code {}, allowed codes: {}".format(
+ return_code,
+ ", ".join(str(code) for code in allowed_codes),
+ )
+ return response.url
+
+ def page_content(self) -> str:
+ self._page.wait_for_load_state("networkidle")
+ return self._page.content()
+
+ def check_page_for_string(self, patt: str) -> None:
+ """Looks for 'patt' in the current browser page"""
+ patt = patt.replace("", "").replace("", "")
+ expect(self._page.locator("body")).to_contain_text(patt)
+
+ def check_string_not_in_page(self, patt: str) -> None:
+ patt = patt.replace("", "").replace("", "")
+ expect(self._page.locator("body")).not_to_contain_text(patt)
+
+ def xcheck_page_for_string(self, patt: str) -> None:
+ page = self.page_content()
+ if page.find(patt) == -1:
+ fname = self.write_temp_file(page)
+ errmsg = f"no match to '{patt}'\npage content written to '{fname}'\npage: [[{page}]]"
+ raise AssertionError(errmsg)
+
+ def xcheck_string_not_in_page(self, patt: str) -> None:
+ page = self.page_content()
+ if page.find(patt) != -1:
+ fname = self.write_temp_file(page)
+ errmsg = f"string ({patt}) incorrectly displayed in page.\npage content written to '{fname}'"
+ raise AssertionError(errmsg)
+
+ def write_temp_file(self, content, suffix=".html"):
+ import tempfile
+
+ from galaxy.util import smart_str
+
+ with tempfile.NamedTemporaryFile(suffix=suffix, prefix="twilltestcase-", delete=False) as fh:
+ fh.write(smart_str(content))
+ return fh.name
+
+ def show_forms(self) -> Locator:
+ """Shows form, helpful for debugging new tests"""
+ return self._page.locator("form")
+
+ def submit_form_with_name(self, form_name: str, button="runtool_btn", **kwd):
+ form = self._form_with_name(form_name)
+ self._submit_form(form, button, **kwd)
+
+ def submit_form(self, form_no=-1, button="runtool_btn", form=None, **kwd):
+ """Populates and submits a form from the keyword arguments."""
+ # An HTMLForm contains a sequence of Controls. Supported control classes are:
+ # TextControl, FileControl, ListControl, RadioControl, CheckboxControl, SelectControl,
+ # SubmitControl, ImageControl
+ if form is None:
+ try:
+ form = self.show_forms().nth(form_no)
+ except IndexError:
+ raise ValueError("No form to submit found")
+ self._submit_form(form, button, **kwd)
+
+ def _submit_form(self, form: Locator, button="runtool_btn", **kwd):
+ for control_name, control_value in kwd.items():
+ self._fill_form_value(form, control_name, control_value)
+ input = self._page.locator(f"input[name='{button}']")
+ if input.count():
+ input.click()
+ else:
+ submit_input = form.locator("input[type=submit]")
+ submit_input.click()
+ import time
+
+ time.sleep(0.25)
+ # tc.submit(button)
+
+ def _form_with_name(self, name: str) -> Locator:
+ forms = self.show_forms()
+ count = forms.count()
+ for i in range(count):
+ nth_form = self.show_forms().nth(i)
+ if nth_form.get_attribute("name") == name:
+ return nth_form
+ raise KeyError(f"No form with name [{name}]")
+
+ def fill_form_value(self, form_name: str, control_name: str, value: str):
+ form: Locator = self._form_with_name(form_name)
+ self._fill_form_value(form, control_name, value)
+
+ def _fill_form_value(self, form: Locator, control_name: str, value: str):
+ input_i = form.locator(f"input[name='{control_name}']")
+ input_t = form.locator(f"textarea[name='{control_name}']")
+ input_s = form.locator(f"select[name='{control_name}']")
+ if input_i.count():
+ if control_name in ["redirect"]:
+ input_i.input_value = value
+ else:
+ input_i.fill(value)
+ if input_t.count():
+ input_t.fill(value)
+ if input_s.count():
+ input_s.select_option(value)
+
+ def edit_repository_categories(self, categories_to_add: List[str], categories_to_remove: List[str]) -> None:
+ multi_select = "form[name='categories'] select[name='category_id']"
+ select_locator = self._page.locator(multi_select)
+ select_locator.evaluate("node => node.selectedOptions = []")
+ select_locator.select_option(label=categories_to_add)
+ self.submit_form_with_name("categories", "manage_categories_button")
+
+ select_locator.evaluate("node => node.selectedOptions = []")
+ select_locator.select_option(label=categories_to_remove)
+ self.submit_form_with_name("categories", "manage_categories_button")
+
+ def grant_users_access(self, usernames: List[str]):
+ multi_select = "form[name='user_access'] select[name='allow_push']"
+ select_locator = self._page.locator(multi_select)
+ select_locator.evaluate("node => node.selectedOptions = []")
+ select_locator.select_option(label=usernames)
+ self.submit_form_with_name("user_access", "user_access_button")
+
+ @property
+ def is_twill(self) -> bool:
+ return False
diff --git a/lib/tool_shed/test/functional/conftest.py b/lib/tool_shed/test/functional/conftest.py
new file mode 100644
index 000000000000..9798b868a212
--- /dev/null
+++ b/lib/tool_shed/test/functional/conftest.py
@@ -0,0 +1,46 @@
+import os
+from typing import (
+ Any,
+ Dict,
+ Generator,
+)
+
+import pytest
+from playwright.sync_api import (
+ Browser,
+ BrowserContext,
+)
+from typing_extensions import Literal
+
+from ..base.browser import ShedBrowser
+from ..base.playwrightbrowser import PlaywrightShedBrowser
+from ..base.twillbrowser import TwillShedBrowser
+
+DEFAULT_BROWSER: Literal["twill", "playwright"] = "playwright"
+
+
+def twill_browser() -> Generator[ShedBrowser, None, None]:
+ yield TwillShedBrowser()
+
+
+def playwright_browser(class_context: BrowserContext) -> Generator[ShedBrowser, None, None]:
+ page = class_context.new_page()
+ yield PlaywrightShedBrowser(page)
+
+
+if os.environ.get("TOOL_SHED_TEST_BROWSER", DEFAULT_BROWSER) == "twill":
+ shed_browser = pytest.fixture(scope="class")(twill_browser)
+else:
+ shed_browser = pytest.fixture(scope="class")(playwright_browser)
+
+
+@pytest.fixture(scope="class")
+def class_context(
+ browser: Browser,
+ browser_context_args: Dict,
+ pytestconfig: Any,
+ request: pytest.FixtureRequest,
+) -> Generator[BrowserContext, None, None]:
+ from pytest_playwright.pytest_playwright import context
+
+ yield from context.__pytest_wrapped__.obj(browser, browser_context_args, pytestconfig, request)
diff --git a/pyproject.toml b/pyproject.toml
index 0a394bf3f907..e5069079c554 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -133,6 +133,7 @@ pytest-html = "*"
python-irodsclient = "!=1.1.2" # https://github.com/irods/python-irodsclient/issues/356
pytest-json-report = "*"
pytest-mock = "*"
+pytest-playwright = "*"
pytest-postgresql = "!=3.0.0" # https://github.com/ClearcodeHQ/pytest-postgresql/issues/426
pytest-shard = "*"
recommonmark = "*"