diff --git a/.github/workflows/toolshed.yaml b/.github/workflows/toolshed.yaml index acb57d3471c4..f28bc8a12598 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: ['v1'] + # 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 c18359a11cc1..f8d303e46d8e 100644 --- a/lib/galaxy/dependencies/dev-requirements.txt +++ b/lib/galaxy/dependencies/dev-requirements.txt @@ -88,6 +88,7 @@ pytest-html==3.2.0 ; python_version >= "3.7" and python_version < "3.12" pytest-json-report==1.5.0 ; python_version >= "3.7" and python_version < "3.12" pytest-metadata==2.0.4 ; python_version >= "3.7" and python_version < "3.12" pytest-mock==3.10.0 ; python_version >= "3.7" and python_version < "3.12" +pytest-playwright==0.3.0 ; python_version >= "3.7" and python_version < "3.12" pytest-postgresql==4.1.1 ; python_version >= "3.7" and python_version < "3.12" pytest-shard==0.1.2 ; python_version >= "3.7" and python_version < "3.12" pytest==7.2.1 ; python_version >= "3.7" and python_version < "3.12" diff --git a/lib/tool_shed/test/base/playwrightbrowser.py b/lib/tool_shed/test/base/playwrightbrowser.py new file mode 100644 index 000000000000..d8e21b5c5277 --- /dev/null +++ b/lib/tool_shed/test/base/playwrightbrowser.py @@ -0,0 +1,153 @@ +import time +from typing import List + +from playwright.sync_api import ( + expect, + Locator, + Page, +) + +from .browser import ( + FormValueType, + 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"[name='{button}']") + if input.count(): + input.click() + else: + submit_input = form.locator("input[type=submit]") + submit_input.click() + 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: FormValueType): + 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: FormValueType): + 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: + if type(value) is bool: + if value and not input_i.is_checked(): + input_i.check() + elif not value and input_i.is_checked(): + input_i.uncheck() + 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/base/twilltestcase.py b/lib/tool_shed/test/base/twilltestcase.py index 0d8f2433c4e8..f44ba143d987 100644 --- a/lib/tool_shed/test/base/twilltestcase.py +++ b/lib/tool_shed/test/base/twilltestcase.py @@ -239,14 +239,18 @@ def display_installed_jobs_list_page( for data_manager_name in data_manager_names: params = {"id": data_managers[data_manager_name]["guid"]} self._visit_galaxy_url("/data_manager/jobs_list", params=params) - self.testcase.check_for_strings(strings_displayed) + content = page_content() + for expected in strings_displayed: + if content.find(expected) == -1: + raise AssertionError(f"Failed to find pattern {expected} in {content}") def installed_repository_extended_info( self, installed_repository: galaxy_model.ToolShedRepository ) -> Dict[str, Any]: params = {"id": self.testcase.security.encode_id(installed_repository.id)} self._visit_galaxy_url("/admin_toolshed/manage_repository_json", params=params) - return loads(self.testcase.last_page()) + json = page_content() + return loads(json) def install_repository( self, @@ -654,6 +658,12 @@ def _browser(self) -> ShedBrowser: assert self.__browser return self.__browser + def _escape_page_content_if_needed(self, content: str) -> str: + # if twill browser is being used - replace spaces with " " + if self._browser.is_twill: + content = content.replace(" ", " ") + return content + def check_for_strings(self, strings_displayed=None, strings_not_displayed=None): strings_displayed = strings_displayed or [] strings_not_displayed = strings_not_displayed or [] 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/lib/tool_shed/test/functional/test_0000_basic_repository_features.py b/lib/tool_shed/test/functional/test_0000_basic_repository_features.py index 029f2987866c..bb34b1aca3fb 100644 --- a/lib/tool_shed/test/functional/test_0000_basic_repository_features.py +++ b/lib/tool_shed/test/functional/test_0000_basic_repository_features.py @@ -108,10 +108,16 @@ def test_0040_verify_repository(self): self.browse_repository( repository, strings_displayed=[f"Repository '{repository.name}' revision", "(repository tip)"] ) + strings = ["Uploaded filtering 1.1.0"] + if self._browser.is_twill: + # this appears in a link - it isn't how one would check this + # in playwright. But also we're testing the mercurial page + # here so this is probably a questionable check overall. + strings += [latest_changeset_revision] self.display_repository_clone_page( common.test_user_1_name, repository_name, - strings_displayed=["Uploaded filtering 1.1.0", latest_changeset_revision], + strings_displayed=strings, ) def test_0045_alter_repository_states(self): @@ -145,33 +151,40 @@ def test_0050_display_repository_tip_file(self): """Display the contents of filtering.xml in the repository tip revision""" repository = self._get_repository_by_name_and_owner(repository_name, common.test_user_1_name) assert repository - self.display_repository_file_contents( - repository=repository, - filename="filtering.xml", - filepath=None, - strings_displayed=["1.1.0"], - strings_not_displayed=[], - ) + if self._browser.is_twill: + # probably not porting this functionality - just test + # with Twill for older UI and drop when that is all dropped + self.display_repository_file_contents( + repository=repository, + filename="filtering.xml", + filepath=None, + strings_displayed=["1.1.0"], + strings_not_displayed=[], + ) def test_0055_upload_filtering_txt_file(self): """Upload filtering.txt file associated with tool version 1.1.0.""" repository = self._get_repository_by_name_and_owner(repository_name, common.test_user_1_name) self.add_file_to_repository(repository, "filtering/filtering_0000.txt") + expected = self._escape_page_content_if_needed("Readme file for filtering 1.1.0") self.display_manage_repository_page( - repository, strings_displayed=["Readme file for filtering 1.1.0"] + repository, strings_displayed=[expected] ) def test_0060_upload_filtering_test_data(self): """Upload filtering test data.""" repository = self._get_repository_by_name_and_owner(repository_name, common.test_user_1_name) self.add_tar_to_repository(repository, "filtering/filtering_test_data.tar") - self.display_repository_file_contents( - repository=repository, - filename="1.bed", - filepath="test-data", - strings_displayed=[], - strings_not_displayed=[], - ) + if self._browser.is_twill: + # probably not porting this functionality - just test + # with Twill for older UI and drop when that is all dropped + self.display_repository_file_contents( + repository=repository, + filename="1.bed", + filepath="test-data", + strings_displayed=[], + strings_not_displayed=[], + ) self.check_repository_metadata(repository, tip_only=True) def test_0065_upload_filtering_2_2_0(self): @@ -208,15 +221,17 @@ def test_0075_upload_readme_txt_file(self): """Upload readme.txt file associated with tool version 2.2.0.""" repository = self._get_repository_by_name_and_owner(repository_name, common.test_user_1_name) self.add_file_to_repository(repository, "readme.txt") + content = self._escape_page_content_if_needed("This is a readme file.") self.display_manage_repository_page( - repository, strings_displayed=["This is a readme file."] + repository, strings_displayed=[content] ) # Verify that there is a different readme file for each metadata revision. + readme_content = self._escape_page_content_if_needed("Readme file for filtering 1.1.0") self.display_manage_repository_page( repository, strings_displayed=[ - "Readme file for filtering 1.1.0", - "This is a readme file.", + readme_content, + content, ], ) @@ -225,8 +240,9 @@ def test_0080_delete_readme_txt_file(self): repository = self._get_repository_by_name_and_owner(repository_name, common.test_user_1_name) self.delete_files_from_repository(repository, filenames=["readme.txt"]) self.check_count_of_metadata_revisions_associated_with_repository(repository, metadata_count=2) + readme_content = self._escape_page_content_if_needed("Readme file for filtering 1.1.0") self.display_manage_repository_page( - repository, strings_displayed=["Readme file for filtering 1.1.0"] + repository, strings_displayed=[readme_content] ) def test_0085_search_for_valid_filter_tool(self): @@ -278,11 +294,14 @@ def test_0110_delete_filtering_repository(self): repository = self._get_repository_by_name_and_owner(repository_name, common.test_user_1_name) self.login(email=common.admin_email, username=common.admin_username) self.delete_repository(repository) + metadata = self._populator.get_metadata(repository, downloadable_only=False) + for _, value in metadata.__root__.items(): + assert not value.downloadable # Explicitly reload all metadata revisions from the database, to ensure that we have the current status of the downloadable flag. # for metadata_revision in repository.metadata_revisions: # self.test_db_util.refresh(metadata_revision) # Marking a repository as deleted should result in no metadata revisions being downloadable. - assert True not in [metadata.downloadable for metadata in self._db_repository(repository).metadata_revisions] + # assert True not in [metadata.downloadable for metadata in self._db_repository(repository).metadata_revisions] def test_0115_undelete_filtering_repository(self): """Undelete the filtering_0000 repository and verify that it now has two downloadable revisions.""" @@ -312,8 +331,9 @@ def test_0125_upload_new_readme_file(self): repository = self._get_repository_by_name_and_owner(repository_name, common.test_user_1_name) # Upload readme.txt to the filtering_0000 repository and verify that it is now displayed. self.add_file_to_repository(repository, "filtering/readme.txt") + content = self._escape_page_content_if_needed("These characters should not") self.display_manage_repository_page( - repository, strings_displayed=["These characters should not"] + repository, strings_displayed=[content] ) def test_0130_verify_handling_of_invalid_characters(self): @@ -331,13 +351,14 @@ def test_0130_verify_handling_of_invalid_characters(self): break # Check for the changeset revision, repository name, owner username, 'repos' in the clone url, and the captured # unicode decoding error message. + content = self._escape_page_content_if_needed("These characters should not") strings_displayed = [ "%d:%s" % (revision_number, revision_hash), "filtering_0000", "user1", "repos", "added:", - "+These characters should not", + f"+{content}", ] self.load_changeset_in_tool_shed(repository_id, changeset_revision, strings_displayed=strings_displayed) @@ -352,9 +373,11 @@ def test_0140_view_invalid_changeset(self): """View repository using an invalid changeset""" repository = self._get_repository_by_name_and_owner(repository_name, common.test_user_1_name) encoded_repository_id = repository.id + assert encoded_repository_id strings_displayed = ["Invalid+changeset+revision"] view_repo_url = ( f"/repository/view_repository?id={encoded_repository_id}&changeset_revision=nonsensical_changeset" ) self.visit_url(view_repo_url) - self.check_for_strings(strings_displayed=strings_displayed, strings_not_displayed=[]) + if self._browser.is_twill: + self.check_for_strings(strings_displayed=strings_displayed, strings_not_displayed=[]) diff --git a/pyproject.toml b/pyproject.toml index e2c036be036a..9465381b5cad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,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 = "*"