From 540f82159c95a4b6e98a4e879ae359d02365586a Mon Sep 17 00:00:00 2001 From: mshriver Date: Wed, 3 Jun 2026 11:22:32 +0200 Subject: [PATCH] Updates for PF6 unit tests Co-authored-by: Claude --- .github/workflows/tests.yaml | 70 ++++++++++++------- pyproject.toml | 29 ++++++++ .../components/chip.py | 3 +- .../components/forms/radio.py | 19 ++++- .../components/menus/dropdown.py | 12 ++-- .../components/menus/menu.py | 45 +++++++++--- .../components/menus/select.py | 11 ++- .../components/navigation.py | 8 ++- .../components/slider.py | 5 +- .../menus/test_dropdown_disabled.py | 18 ++++- testing/components/test_alert.py | 6 ++ testing/conftest.py | 1 + 12 files changed, 177 insertions(+), 50 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4685ef6..748f9de 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,20 +21,30 @@ jobs: outputs: playwright-version: ${{ steps.playwright-version.outputs.version }} steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Install Playwright - run: pip install playwright + - name: Install hatch + run: pip install hatch + + - name: Cache hatch environment + uses: actions/cache@v4 + with: + path: ~/.local/share/hatch + key: hatch-${{ runner.os }}-3.11-${{ hashFiles('pyproject.toml') }} + restore-keys: | + hatch-${{ runner.os }}-3.11- + + - name: Create test environment + run: hatch env create test - name: Get Playwright version id: playwright-version - run: echo "version=$(pip show playwright | grep Version | cut -d' ' -f2)" >> $GITHUB_OUTPUT + run: | + echo "version=$(hatch run test:python -c 'import playwright; print(playwright.__version__)')" >> $GITHUB_OUTPUT - name: Cache Playwright browsers uses: actions/cache@v4 @@ -47,11 +57,10 @@ jobs: - name: Install browsers if: steps.playwright-cache.outputs.cache-hit != 'true' - run: | - playwright install chromium firefox - playwright install-deps + run: hatch run test:install-browsers + test: - name: pf-${{ matrix.pf-version }} (🐍 ${{ matrix.python-version }}, ${{ matrix.browser }}) + name: pf-v${{ matrix.pf-version }} (🐍 ${{ matrix.python-version }}, ${{ matrix.browser }}) runs-on: ubuntu-latest needs: setup-browsers timeout-minutes: 30 @@ -60,12 +69,14 @@ jobs: matrix: browser: [chromium, firefox] python-version: ["3.12", "3.13"] - pf-version: ["v5", "v6"] - # Reduce redundancy: only run coverage for one combination + # Values are the numeric suffix only so they compose directly into + # the hatch script names pf5 / pf6 and the --pf-version=v5 / v6 flag. + pf-version: ["5", "6"] + # Run coverage only for one combination to keep CI lean include: - browser: chromium python-version: "3.13" - pf-version: "v6" + pf-version: "6" run-coverage: true exclude: [] steps: @@ -73,10 +84,21 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python-version }} + + - name: Install hatch + run: pip install hatch + + - name: Cache hatch environment + uses: actions/cache@v4 + with: + path: ~/.local/share/hatch + key: hatch-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} + restore-keys: | + hatch-${{ runner.os }}-${{ matrix.python-version }}- - - name: Install Playwright - run: pip install playwright + - name: Create test environment + run: hatch env create test - name: Restore Playwright browsers cache uses: actions/cache/restore@v4 @@ -85,22 +107,22 @@ jobs: key: playwright-${{ runner.os }}-${{ needs.setup-browsers.outputs.playwright-version }}-browsers fail-on-cache-miss: true - - name: Install dependencies - run: | - pip install -U pip wheel - pip install -e .[dev] - - name: Test with pytest (with coverage) if: matrix.run-coverage == true timeout-minutes: 25 run: | - pytest -v -n 2 --headless --browser=${{ matrix.browser }} --pf-version=${{ matrix.pf-version }} --cov=./src --cov-report=xml --reruns 2 --reruns-delay 5 + hatch run test:pf${{ matrix.pf-version }} \ + -n 2 --browser=${{ matrix.browser }} \ + --reruns 2 --reruns-delay 5 \ + --cov=./src --cov-report=xml - name: Test with pytest (without coverage) if: matrix.run-coverage != true timeout-minutes: 25 run: | - pytest -v -n 2 --headless --browser=${{ matrix.browser }} --pf-version=${{ matrix.pf-version }} --reruns 2 --reruns-delay 5 + hatch run test:pf${{ matrix.pf-version }} \ + -n 2 --browser=${{ matrix.browser }} \ + --reruns 2 --reruns-delay 5 - name: Upload coverage to Codecov if: matrix.run-coverage == true diff --git a/pyproject.toml b/pyproject.toml index c275be7..f985518 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ "pytest-rerunfailures", "pytest-timeout", "codecov", + "playwright", ] doc = ["sphinx"] @@ -52,6 +53,34 @@ packages = ["src/widgetastic_patternfly5"] source = "vcs" raw-options.version_scheme = "calver-by-date" +[tool.hatch.envs.test] +features = ["dev"] + +[tool.hatch.envs.test.scripts] +# Installs browser binaries and Linux system-level dependencies. +# Run once after setting up the environment: hatch run test:install-browsers +install-browsers = [ + "playwright install chromium firefox", + "playwright install-deps", +] +# Run all tests (headless) – PF version defaults to v6 per conftest +all = "pytest -v --headless {args}" +# PF-version-specific convenience scripts used locally and in CI +pf5 = "pytest -v --headless --pf-version=v5 {args}" +pf6 = "pytest -v --headless --pf-version=v6 {args}" +# Headed mode for local interactive debugging +debug = "pytest -v --pf-version=v6 {args}" + +[tool.hatch.envs.lint] +dependencies = ["pre-commit"] + +[tool.hatch.envs.lint.scripts] +check = "pre-commit run --all-files" + +[tool.pytest.ini_options] +testpaths = ["testing"] +timeout = 60 + [tool.ruff] line-length = 100 diff --git a/src/widgetastic_patternfly5/components/chip.py b/src/widgetastic_patternfly5/components/chip.py index e382985..a10987a 100644 --- a/src/widgetastic_patternfly5/components/chip.py +++ b/src/widgetastic_patternfly5/components/chip.py @@ -246,7 +246,8 @@ def can_close(self): return self.close_button.is_displayed def close(self): - self.close_button.click() + close_el = self.browser.element(CATEGORY_CLOSE) + close_el.dispatch_event("click") @classmethod def all(cls, browser): diff --git a/src/widgetastic_patternfly5/components/forms/radio.py b/src/widgetastic_patternfly5/components/forms/radio.py index 3063a66..107493a 100644 --- a/src/widgetastic_patternfly5/components/forms/radio.py +++ b/src/widgetastic_patternfly5/components/forms/radio.py @@ -41,7 +41,7 @@ class Radio(BaseRadio, View): @property def selected(self): - return self.radio.selected + return self.browser.is_checked(self.RADIO_LOC) @property def disabled(self): @@ -49,4 +49,19 @@ def disabled(self): def fill(self, values): """Can only handle `True` to check the radio, nature of individual radio button""" - return self.radio.fill(values) + if values == self.selected: + return False + if values: + el = self.browser.element(self.RADIO_LOC) + el.evaluate( + "e => {" + " const nativeSetter = Object.getOwnPropertyDescriptor(" + " window.HTMLInputElement.prototype, 'checked'" + " ).set;" + " nativeSetter.call(e, true);" + " e.dispatchEvent(new Event('click', {bubbles: true}));" + " e.dispatchEvent(new Event('input', {bubbles: true}));" + " e.dispatchEvent(new Event('change', {bubbles: true}));" + "}" + ) + return True diff --git a/src/widgetastic_patternfly5/components/menus/dropdown.py b/src/widgetastic_patternfly5/components/menus/dropdown.py index 89fcfbd..3b55b72 100644 --- a/src/widgetastic_patternfly5/components/menus/dropdown.py +++ b/src/widgetastic_patternfly5/components/menus/dropdown.py @@ -1,6 +1,7 @@ from contextlib import contextmanager from cached_property import cached_property +from wait_for import wait_for as _wait_for from widgetastic.exceptions import NoSuchElementException from widgetastic.utils import ParametrizedLocator from widgetastic.widget import Widget @@ -83,12 +84,11 @@ def open(self): if self.is_open: return - # @wait_for_decorator(timeout=3) - # def _click(): - # self.browser.click(self.BUTTON_LOCATOR) - # return self.is_open - el = self.browser.element(self.BUTTON_LOCATOR) - self.browser.click(el) + def _click(): + self.browser.click(self.BUTTON_LOCATOR) + return self.is_open + + _wait_for(_click, timeout=10, delay=0.5) return self.is_open def close(self, ignore_nonpresent=False): diff --git a/src/widgetastic_patternfly5/components/menus/menu.py b/src/widgetastic_patternfly5/components/menus/menu.py index c596f0d..43ab2ad 100644 --- a/src/widgetastic_patternfly5/components/menus/menu.py +++ b/src/widgetastic_patternfly5/components/menus/menu.py @@ -1,3 +1,4 @@ +from wait_for import wait_for as _wait_for from widgetastic.exceptions import NoSuchElementException from .dropdown import Dropdown, DropdownItemDisabled, DropdownItemNotFound @@ -90,6 +91,19 @@ def close(self, ignore_nonpresent=False): def item_element(self, item, close=True): """Returns a WebElement for given item name.""" + if self.IS_ALWAYS_OPEN: + browser_els = self.browser.elements(self.ITEMS_LOCATOR) + items_els = browser_els or self.root_browser.elements(self.ITEMS_LOCATOR) + for el in items_els: + if self.browser.text(el).strip() == item: + try: + inp = self.browser.element(parent=el, locator=".//input") + except NoSuchElementException: + inp = el + return inp + raise MenuItemNotFound( + f"Item {item!r} not found in {repr(self)}. Available items: {self.items}" + ) try: return super().item_element(item, close) except DropdownItemNotFound: @@ -153,9 +167,15 @@ def item_select(self, items, close=True): try: for item in items: - element = self.item_element(item, close=False) - if not self.browser.is_selected(element): - element.click() + + def _try_select(): + element = self.item_element(item, close=False) + if not self.browser.is_selected(element): + element.click() + return self.browser.is_selected(element) + return True + + _wait_for(_try_select, timeout=10, delay=0.5) finally: if close: self.close() @@ -172,9 +192,15 @@ def item_deselect(self, items, close=True): try: for item in items: - element = self.item_element(item, close=False) - if self.browser.is_selected(element): - element.click() + + def _try_deselect(): + element = self.item_element(item, close=False) + if self.browser.is_selected(element): + element.click() + return not self.browser.is_selected(element) + return True + + _wait_for(_try_deselect, timeout=10, delay=0.5) finally: if close: self.close() @@ -205,10 +231,9 @@ def read(self): for el in item_elements: item = self.browser.text(el) try: - # get the child element of the label - selected[item] = self.browser.element( - parent=el, locator=".//input" - ).is_checked() + inp = self.browser.element(parent=el, locator=".//input") + checked = inp.is_checked() + selected[item] = checked except NoSuchElementException: selected[item] = False diff --git a/src/widgetastic_patternfly5/components/menus/select.py b/src/widgetastic_patternfly5/components/menus/select.py index 63555c5..5c113d2 100644 --- a/src/widgetastic_patternfly5/components/menus/select.py +++ b/src/widgetastic_patternfly5/components/menus/select.py @@ -1,3 +1,4 @@ +from wait_for import wait_for as _wait_for from widgetastic.exceptions import NoSuchElementException from widgetastic.widget import TextInput @@ -220,7 +221,15 @@ def fill(self, value, create_item=False): if create_item and value not in self.items: self.input.fill(value) - self.root_browser.click(self.CREATE_ITEM_LOCATOR) + _id_attr = self.CREATE_ITEM_LOCATOR.split("@id='")[1].rstrip("']") + create_css = "#" + _id_attr + page = self.browser.element(".").page + _wait_for( + lambda: page.locator(create_css).count() > 0, + timeout=10, + delay=0.2, + ) + page.locator(create_css).click() return True else: self.item_select(value) diff --git a/src/widgetastic_patternfly5/components/navigation.py b/src/widgetastic_patternfly5/components/navigation.py index 3fd6eea..10fbb90 100644 --- a/src/widgetastic_patternfly5/components/navigation.py +++ b/src/widgetastic_patternfly5/components/navigation.py @@ -117,10 +117,14 @@ def select(self, *levels, **kwargs): f"Could not find element: '{self.ITEM_MATCHING.format(quote(level))}'" ) if "pf-m-expanded" not in li.get_attribute("class").split(): - self.browser.click(li) + link_el = self.browser.element(".//*[self::a or self::button]", parent=li) + link_el.dispatch_event("click") if i == len(levels): return - current_item = self.browser.element(self.SUB_ITEMS_ROOT, parent=li) + try: + current_item = self.browser.element(self.SUB_ITEMS_ROOT, parent=li) + except NoSuchElementException: + raise def __repr__(self): return f"{type(self).__name__}({self.ROOT!r})" diff --git a/src/widgetastic_patternfly5/components/slider.py b/src/widgetastic_patternfly5/components/slider.py index 9a8e8a5..3dd2f83 100644 --- a/src/widgetastic_patternfly5/components/slider.py +++ b/src/widgetastic_patternfly5/components/slider.py @@ -1,3 +1,4 @@ +from wait_for import wait_for as _wait_for from widgetastic.widget import GenericLocatorWidget @@ -98,7 +99,9 @@ def fill(self, value): if self.text == value: return False el = self.browser.element(self.INPUT) - el.press("Control+A") + el.focus() el.fill(str(value)) + el.dispatch_event("change") el.press("Enter") + _wait_for(lambda: self.text == value, timeout=10, delay=0.2) return True diff --git a/testing/components/menus/test_dropdown_disabled.py b/testing/components/menus/test_dropdown_disabled.py index 65174f7..5cbb277 100644 --- a/testing/components/menus/test_dropdown_disabled.py +++ b/testing/components/menus/test_dropdown_disabled.py @@ -8,13 +8,25 @@ TESTING_PAGE_COMPONENT = "components/menus/dropdown/react-templates/simple" +# In PF5 the Dropdown renders a wrapper div (pf-vX-c-dropdown) around the +# MenuToggle button. In PF6 the Dropdown uses Popper inline rendering, so the +# button's parent is a plain wrapper div with no PF class. +# +# The original locator relied on data-ouia-component-id="default-1" which +# PF6 never generates (OUIA IDs are auto-generated, not "default-1"). +# +# This locator finds the first MenuToggle button with text "Dropdown" then +# steps up to its parent — the natural ROOT for the Dropdown widget regardless +# of PF version. +_DROPDOWN_LOCATOR = ( + ".//button[contains(@class, '-c-menu-toggle') and normalize-space(.)='Dropdown'][1]/.." +) + @pytest.fixture def view(browser): class TestView(View): - dropdown_custom_locator = Dropdown( - locator=".//button[contains(@data-ouia-component-type, '/MenuToggle') and contains(@data-ouia-component-id, 'default-1')]/parent::div" - ) + dropdown_custom_locator = Dropdown(locator=_DROPDOWN_LOCATOR) disable_checkbox = Checkbox(id="simple-example-disabled-toggle") view = TestView(browser) diff --git a/testing/components/test_alert.py b/testing/components/test_alert.py index 65a8c5c..e1265b6 100644 --- a/testing/components/test_alert.py +++ b/testing/components/test_alert.py @@ -6,10 +6,16 @@ TESTING_PAGE_COMPONENT = "components/alert" ALERT_TYPES = ["success", "danger", "warning", "info"] +# The "Alert variants" demo section ID on patternfly.org. +# Scoping to this section avoids the site-wide "Website update" info +# notification that appears as the first pf-m-info alert on PF6. +ALERT_VARIANTS_SECTION = "ws-react-c-alert-alert-variants" + @pytest.fixture(params=ALERT_TYPES) def alert(browser, request): class TestView(View): + ROOT = f".//div[@id='{ALERT_VARIANTS_SECTION}']" alert = Alert(locator=f".//div[contains(@class, '-c-alert pf-m-{request.param}')][1]") return TestView(browser).alert diff --git a/testing/conftest.py b/testing/conftest.py index a8133a5..02e5daf 100644 --- a/testing/conftest.py +++ b/testing/conftest.py @@ -90,6 +90,7 @@ def playwright_browser_instance(request, browser_name: str) -> PlaywrightBrowser def browser_context(playwright_browser_instance: PlaywrightBrowser) -> BrowserContext: """Creates a browser context for the entire test session.""" context = playwright_browser_instance.new_context(viewport={"width": 1920, "height": 1080}) + context.set_default_timeout(5_000) yield context context.close()