From 1ac3caa3764500abc83ed0ffd946799808aa676c Mon Sep 17 00:00:00 2001 From: mshriver Date: Wed, 3 Jun 2026 11:22:32 +0200 Subject: [PATCH 1/5] Updates for PF6 unit tests Co-authored-by: Claude --- .github/workflows/tests.yaml | 70 ++++++++++++------- pyproject.toml | 1 + .../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 | 19 +++-- testing/components/test_alert.py | 10 ++- testing/conftest.py | 1 + 12 files changed, 150 insertions(+), 54 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 da92e1b..166b12b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ "pytest-rerunfailures", "pytest-timeout", "codecov", + "playwright", ] doc = ["sphinx"] diff --git a/src/widgetastic_patternfly5/components/chip.py b/src/widgetastic_patternfly5/components/chip.py index 9db4c6f..0ee1d4c 100644 --- a/src/widgetastic_patternfly5/components/chip.py +++ b/src/widgetastic_patternfly5/components/chip.py @@ -248,7 +248,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 0473ebc..f419b7f 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 64525fc..5cbb277 100644 --- a/testing/components/menus/test_dropdown_disabled.py +++ b/testing/components/menus/test_dropdown_disabled.py @@ -8,14 +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): - ROOT = './/div[@id="ws-react-templates-c-dropdown-simple"]' - dropdown_custom_locator = Dropdown( - locator=".//button[contains(@data-ouia-component-type, '/MenuToggle')][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 6169b0f..e1265b6 100644 --- a/testing/components/test_alert.py +++ b/testing/components/test_alert.py @@ -6,13 +6,17 @@ 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): - alert = Alert( - locator=f".//div[contains(@class, '-c-alert pf-m-{request.param}') and not(contains(@class, 'ws-nav-announcement'))][1]" - ) + 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() From 03518feabf1ce954518c3c2e51e07c1c7840f11d Mon Sep 17 00:00:00 2001 From: mshriver Date: Tue, 21 Jul 2026 10:00:07 -0400 Subject: [PATCH 2/5] Move aria disabled check for dropdown Co-authored-by: Claude --- .../components/menus/dropdown.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/widgetastic_patternfly5/components/menus/dropdown.py b/src/widgetastic_patternfly5/components/menus/dropdown.py index f419b7f..eab27d7 100644 --- a/src/widgetastic_patternfly5/components/menus/dropdown.py +++ b/src/widgetastic_patternfly5/components/menus/dropdown.py @@ -164,12 +164,19 @@ def item_enabled(self, item, close=True, **kwargs): el = self.item_element(item, close=False, **kwargs) if self.browser.get_attribute("type", el) == "checkbox": - # input element don't have such disabled attributes it at level of session. is_el_enabled = el.is_enabled() else: + el_classes = self.browser.classes(el) aria_disabled = str(self.browser.get_attribute("aria-disabled", el)).lower() + # PF6 puts aria-disabled on a child element (button/a) rather than the li + if aria_disabled != "true": + child_els = self.browser.elements(".//*[@aria-disabled='true']", parent=el) + if child_els: + aria_disabled = "true" is_el_enabled = ( - "pf-m-disabled" not in self.browser.classes(el) and aria_disabled != "true" + "pf-m-disabled" not in el_classes + and "pf-m-aria-disabled" not in el_classes + and aria_disabled != "true" ) if close: From 140b6f29880578a9a919f1d0824cbc9693f0746a Mon Sep 17 00:00:00 2001 From: mshriver Date: Tue, 21 Jul 2026 10:31:43 -0400 Subject: [PATCH 3/5] Updates from sourcery: chip, bullet, radio, select Co-authored-by: Claude --- .../charts/bullet_chart.py | 2 +- .../components/chip.py | 21 ++++++++---- .../components/forms/radio.py | 34 +++++++++++-------- .../components/menus/dropdown.py | 15 +++++--- .../components/menus/menu.py | 4 +-- .../components/menus/select.py | 14 ++++---- .../components/navigation.py | 4 ++- .../components/slider.py | 5 ++- 8 files changed, 61 insertions(+), 38 deletions(-) diff --git a/src/widgetastic_patternfly5/charts/bullet_chart.py b/src/widgetastic_patternfly5/charts/bullet_chart.py index 7c1bfda..947209e 100644 --- a/src/widgetastic_patternfly5/charts/bullet_chart.py +++ b/src/widgetastic_patternfly5/charts/bullet_chart.py @@ -1,7 +1,7 @@ import re import time +from functools import cached_property -from cached_property import cached_property from widgetastic.utils import ParametrizedLocator from widgetastic.widget import Text, View from widgetastic.xpath import quote diff --git a/src/widgetastic_patternfly5/components/chip.py b/src/widgetastic_patternfly5/components/chip.py index 0ee1d4c..14a486c 100644 --- a/src/widgetastic_patternfly5/components/chip.py +++ b/src/widgetastic_patternfly5/components/chip.py @@ -41,6 +41,9 @@ class _BaseChip(View): Holds attributes shared by both Chip and OverflowChip """ + WAIT_TIMEOUT = 3 + WAIT_DELAY = 0.1 + _text = Text(CHIP_TEXT) _badge = Text(f"{CHIP_TEXT}/{CHIP_BADGE}") button = Button() @@ -106,7 +109,12 @@ def _gone(): if not self.read_only: self.button.click() - wait_for(_gone, timeout=3, message="wait for chip to disappear", delay=0.1) + wait_for( + _gone, + timeout=self.WAIT_TIMEOUT, + delay=self.WAIT_DELAY, + message="wait for chip to disappear", + ) else: raise ChipReadOnlyError(self, "Chip is read-only") @@ -135,8 +143,8 @@ def show_more(self): self._text.click() wait_for( func=self._show_less_shown, - timeout=3, - delay=0.1, + timeout=self.WAIT_TIMEOUT, + delay=self.WAIT_DELAY, message="wait for 'show less' button to appear", ) @@ -146,8 +154,8 @@ def show_less(self): self._text.click() wait_for( self._show_more_shown, - timeout=3, - delay=0.1, + timeout=self.WAIT_TIMEOUT, + delay=self.WAIT_DELAY, message="wait for 'show more' button to appear", ) @@ -248,8 +256,7 @@ def can_close(self): return self.close_button.is_displayed def close(self): - close_el = self.browser.element(CATEGORY_CLOSE) - close_el.dispatch_event("click") + self.browser.element(CATEGORY_CLOSE).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 107493a..0584c0d 100644 --- a/src/widgetastic_patternfly5/components/forms/radio.py +++ b/src/widgetastic_patternfly5/components/forms/radio.py @@ -48,20 +48,24 @@ def disabled(self): return "pf-m-disabled" in self.browser.classes(self.label) def fill(self, values): - """Can only handle `True` to check the radio, nature of individual radio button""" - if values == self.selected: + """Fill the radio button. Only ``True`` is meaningful — radio buttons + cannot be unchecked, so ``False`` is always a no-op. + + Returns: + ``True`` if the state changed, ``False`` otherwise. + """ + if not values or self.selected or self.disabled: 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}));" - "}" - ) + 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 eab27d7..3391496 100644 --- a/src/widgetastic_patternfly5/components/menus/dropdown.py +++ b/src/widgetastic_patternfly5/components/menus/dropdown.py @@ -1,6 +1,6 @@ from contextlib import contextmanager +from functools import cached_property -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 @@ -30,6 +30,9 @@ class BaseDropdown: """ + WAIT_TIMEOUT = 10 + WAIT_DELAY = 0.5 + BUTTON_LOCATOR = ( ".//button[contains(@class, '-c-menu-toggle') or contains(@class, '-c-dropdown__toggle')]" ) @@ -88,7 +91,7 @@ def _click(): self.browser.click(self.BUTTON_LOCATOR) return self.is_open - _wait_for(_click, timeout=10, delay=0.5) + _wait_for(_click, timeout=self.WAIT_TIMEOUT, delay=self.WAIT_DELAY) return self.is_open def close(self, ignore_nonpresent=False): @@ -229,9 +232,13 @@ def __repr__(self): class Dropdown(BaseDropdown, Widget): ROOT = ParametrizedLocator("{@locator}") TEXT_LOCATOR = ( - './/div[contains(@class, "-c-dropdown") and child::button[normalize-space(.)={}]]' + '(.//div[contains(@class, "-c-dropdown") and child::button[normalize-space(.)={0}]]' + ' | .//button[contains(@class, "-c-menu-toggle") and normalize-space(.)={0}]/..)[1]' + ) + DEFAULT_LOCATOR = ( + '(.//div[contains(@class, "-c-dropdown")]' + ' | .//button[contains(@class, "-c-menu-toggle")]/..)[1]' ) - DEFAULT_LOCATOR = './/div[contains(@class, "-c-dropdown")][1]' def __init__(self, parent, text=None, locator=None, logger=None): super().__init__(parent, logger=logger) diff --git a/src/widgetastic_patternfly5/components/menus/menu.py b/src/widgetastic_patternfly5/components/menus/menu.py index 43ab2ad..faa1698 100644 --- a/src/widgetastic_patternfly5/components/menus/menu.py +++ b/src/widgetastic_patternfly5/components/menus/menu.py @@ -175,7 +175,7 @@ def _try_select(): return self.browser.is_selected(element) return True - _wait_for(_try_select, timeout=10, delay=0.5) + _wait_for(_try_select, timeout=self.WAIT_TIMEOUT, delay=self.WAIT_DELAY) finally: if close: self.close() @@ -200,7 +200,7 @@ def _try_deselect(): return not self.browser.is_selected(element) return True - _wait_for(_try_deselect, timeout=10, delay=0.5) + _wait_for(_try_deselect, timeout=self.WAIT_TIMEOUT, delay=self.WAIT_DELAY) finally: if close: self.close() diff --git a/src/widgetastic_patternfly5/components/menus/select.py b/src/widgetastic_patternfly5/components/menus/select.py index 5c113d2..989b6cf 100644 --- a/src/widgetastic_patternfly5/components/menus/select.py +++ b/src/widgetastic_patternfly5/components/menus/select.py @@ -197,6 +197,9 @@ class BaseTypeaheadSelect(BaseSelect): https://www.patternfly.org/components/menus/select/#typeahead """ + WAIT_TIMEOUT = 10 + WAIT_DELAY = 0.2 + BUTTON_LOCATOR = ( ".//button[(contains(@class, '-c-select__toggle') " "or contains(@class, '-c-menu-toggle')) " @@ -221,15 +224,12 @@ def fill(self, value, create_item=False): if create_item and value not in self.items: self.input.fill(value) - _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, + lambda: self.root_browser.elements(self.CREATE_ITEM_LOCATOR), + timeout=self.WAIT_TIMEOUT, + delay=self.WAIT_DELAY, ) - page.locator(create_css).click() + self.root_browser.click(self.CREATE_ITEM_LOCATOR) return True else: self.item_select(value) diff --git a/src/widgetastic_patternfly5/components/navigation.py b/src/widgetastic_patternfly5/components/navigation.py index 10fbb90..55bbbe2 100644 --- a/src/widgetastic_patternfly5/components/navigation.py +++ b/src/widgetastic_patternfly5/components/navigation.py @@ -25,6 +25,8 @@ class BaseNavigation: https://www.patternfly.org/components/navigation """ + WAIT_TIMEOUT = 10 + CURRENTLY_SELECTED = ( './/*[self::a or self::button][contains(@class, "pf-m-current") or ' 'parent::li[contains(@class, "pf-m-current")]]' @@ -41,7 +43,7 @@ def loaded(self): self.logger.info("Navigation not ready yet") wait_for( lambda: self.browser.element(".").get_attribute("data-ouia-safe") == "true", - timeout=10, + timeout=self.WAIT_TIMEOUT, ) elif not out: self.logger.info("Navigation doesn't have 'data-ouia-safe' property") diff --git a/src/widgetastic_patternfly5/components/slider.py b/src/widgetastic_patternfly5/components/slider.py index 3dd2f83..139b665 100644 --- a/src/widgetastic_patternfly5/components/slider.py +++ b/src/widgetastic_patternfly5/components/slider.py @@ -92,6 +92,9 @@ class Slider(BaseSlider, GenericLocatorWidget): class InputSlider(Slider): + WAIT_TIMEOUT = 10 + WAIT_DELAY = 0.2 + INPUT = ".//input" def fill(self, value): @@ -103,5 +106,5 @@ def fill(self, value): el.fill(str(value)) el.dispatch_event("change") el.press("Enter") - _wait_for(lambda: self.text == value, timeout=10, delay=0.2) + _wait_for(lambda: self.text == value, timeout=self.WAIT_TIMEOUT, delay=self.WAIT_DELAY) return True From ed810a1b3041a4ec66ec0cc2a98249294dd72702 Mon Sep 17 00:00:00 2001 From: mshriver Date: Tue, 21 Jul 2026 12:46:30 -0400 Subject: [PATCH 4/5] Fix CI timeouts: set separate navigation timeout for Playwright In Playwright, set_default_timeout() applies to ALL operations including navigation. The 5s default is too aggressive for page.goto() calls with wait_until='networkidle' against external patternfly.org sites in CI environments where network latency is higher. Use set_default_navigation_timeout(30_000) explicitly so navigation gets a 30s timeout while element interactions keep the 5s timeout. Co-authored-by: Claude --- testing/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/conftest.py b/testing/conftest.py index 02e5daf..758ea2b 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_navigation_timeout(30_000) context.set_default_timeout(5_000) yield context context.close() From 5d6e58c4ba9d4dda508fcb016106e09d3bbaabb8 Mon Sep 17 00:00:00 2001 From: mshriver Date: Tue, 21 Jul 2026 13:18:27 -0400 Subject: [PATCH 5/5] Use timeout kwarg integer to wait_displayed Co-authored-by: Claude --- testing/components/menus/test_dropdown.py | 2 +- testing/components/menus/test_dropdown_disabled.py | 2 +- testing/components/menus/test_group_dropdown.py | 2 +- testing/components/test_card.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/testing/components/menus/test_dropdown.py b/testing/components/menus/test_dropdown.py index 232dee8..5110d79 100644 --- a/testing/components/menus/test_dropdown.py +++ b/testing/components/menus/test_dropdown.py @@ -21,7 +21,7 @@ class TestView(View): dropdown_default_locator = Dropdown() view = TestView(browser) - view.wait_displayed("10s") + view.wait_displayed(timeout=10) return view diff --git a/testing/components/menus/test_dropdown_disabled.py b/testing/components/menus/test_dropdown_disabled.py index 5cbb277..c5fdd1d 100644 --- a/testing/components/menus/test_dropdown_disabled.py +++ b/testing/components/menus/test_dropdown_disabled.py @@ -30,7 +30,7 @@ class TestView(View): disable_checkbox = Checkbox(id="simple-example-disabled-toggle") view = TestView(browser) - view.wait_displayed("10s") + view.wait_displayed(timeout=10) return view diff --git a/testing/components/menus/test_group_dropdown.py b/testing/components/menus/test_group_dropdown.py index 7d64625..3ed1f96 100644 --- a/testing/components/menus/test_group_dropdown.py +++ b/testing/components/menus/test_group_dropdown.py @@ -14,7 +14,7 @@ def group_dropdown(browser): browser, locator=".//div[@id='ws-react-c-dropdown-with-groups-of-items']", ) - dropdown.wait_displayed("10s") + dropdown.wait_displayed(timeout=10) return dropdown diff --git a/testing/components/test_card.py b/testing/components/test_card.py index b76f959..8903084 100644 --- a/testing/components/test_card.py +++ b/testing/components/test_card.py @@ -56,7 +56,7 @@ def __init__(self, parent, locator=None, logger=None, **kwargs): @pytest.fixture def cards(browser): cards = Cards(browser) - cards.wait_displayed("15s") + cards.wait_displayed(timeout=15) return cards