From dfe420412635393a6916f19826be426a594a4d7c Mon Sep 17 00:00:00 2001 From: John Chilton Date: Thu, 15 Sep 2022 19:45:23 -0400 Subject: [PATCH] Modernized framework to replace tool shed functional tests. Goals: - Replace direct database access with pydantic typed API requests. - Replace Twill with playwright. - Eliminate access to the Galaxy UI in the tests - the tests should assume API access from the installing party. pytest-playwright requires small bits of #13909. This is a worthy project that could let us remove a bunch of deprecated stuff from the Galaxy admin controllers and a bunch of mako stuff that is unused and could be used to test PRs like #14609 (which prompted me to do this) but I'm anxious about growing emotionally attached to code I want to remove and I'm worried about losing track of which helpers are required for Planemo/Emphemeris/Galaxy and which helpers are just being used to test the tool shed. --- .github/workflows/toolshed.yaml | 17 ++- lib/galaxy/dependencies/dev-requirements.txt | 1 + lib/tool_shed/test/base/playwrightbrowser.py | 145 +++++++++++++++++++ lib/tool_shed/test/functional/conftest.py | 46 ++++++ pyproject.toml | 1 + 5 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 lib/tool_shed/test/base/playwrightbrowser.py create mode 100644 lib/tool_shed/test/functional/conftest.py diff --git a/.github/workflows/toolshed.yaml b/.github/workflows/toolshed.yaml index acb57d3471c4..969d2996ff4b 100644 --- a/.github/workflows/toolshed.yaml +++ b/.github/workflows/toolshed.yaml @@ -22,7 +22,12 @@ jobs: matrix: python-version: ['3.7'] test-install-client: ['standalone', 'galaxy_api'] - shed-api: ['v1', 'v2'] + # v1 is mostly working... + shed-api: ['v2'] + # lets get twill working with twill then try to + # make progress on the playwright + # shed-browser: ['twill', 'playwright'] + shed-browser: ['playwright'] services: postgres: image: postgres:13 @@ -54,11 +59,21 @@ jobs: with: path: 'galaxy_root/.venv' key: gxy-venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('galaxy_root/requirements.txt') }}-toolshed + - name: Install dependencies + run: ./scripts/common_startup.sh --skip-client-build + working-directory: 'galaxy_root' + - name: Build Frontend + run: '. .venv/bin/activate && cd lib/tool_shed/webapp/frontend && yarn && make client' + working-directory: 'galaxy_root' + - name: Install playwright + run: '. .venv/bin/activate && playwright install' + working-directory: 'galaxy_root' - name: Run tests run: './run_tests.sh -toolshed' 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 afb44df79ccd..9b80c149ab55 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.1 ; 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 be15504dfe05..85b2108a8825 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,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 = "*"