Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 46 additions & 24 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -60,23 +69,36 @@ 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:
- uses: actions/checkout@v4

- 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
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dev = [
"pytest-rerunfailures",
"pytest-timeout",
"codecov",
"playwright",
]
doc = ["sphinx"]

Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion src/widgetastic_patternfly5/components/chip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines +249 to +250

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Use the class attribute for CATEGORY_CLOSE and leverage the existing close_button widget.

CATEGORY_CLOSE is not defined in this scope and should be referenced as the class attribute (e.g. self.CATEGORY_CLOSE), otherwise this will raise a NameError.

Also, the previous version used self.close_button, which likely wraps the correct locator and behavior. To stay consistent with the existing API and avoid regressions, consider either:

  • close_el = self.browser.element(self.CATEGORY_CLOSE), or
  • Preferably, self.close_button.dispatch_event("click") to reuse the widget abstraction.

Comment on lines 248 to +250

@classmethod
def all(cls, browser):
Expand Down
19 changes: 17 additions & 2 deletions src/widgetastic_patternfly5/components/forms/radio.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,27 @@ class Radio(BaseRadio, View):

@property
def selected(self):
return self.radio.selected
return self.browser.is_checked(self.RADIO_LOC)

@property
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"""
return self.radio.fill(values)
if values == self.selected:
return False
if values:
Comment on lines 50 to +54
Comment on lines 50 to +54
el = self.browser.element(self.RADIO_LOC)
el.evaluate(
"e => {"
" const nativeSetter = Object.getOwnPropertyDescriptor("
" window.HTMLInputElement.prototype, 'checked'"
" ).set;"
" nativeSetter.call(e, true);"
Comment on lines +52 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Clarify behavior when values is False for an already-checked radio.

With the current logic, calling fill(False) on a selected radio skips the early return, skips the if values: block, and still returns True, suggesting a change occurred when nothing actually changed.

Consider either treating False as a no-op that returns False, or explicitly disallowing False (e.g., by raising) and documenting that fill only supports True. Otherwise, callers may misinterpret the return value and assume the radio was updated when it was not.

" e.dispatchEvent(new Event('click', {bubbles: true}));"
" e.dispatchEvent(new Event('input', {bubbles: true}));"
" e.dispatchEvent(new Event('change', {bubbles: true}));"
"}"
)
return True
12 changes: 6 additions & 6 deletions src/widgetastic_patternfly5/components/menus/dropdown.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down
45 changes: 35 additions & 10 deletions src/widgetastic_patternfly5/components/menus/menu.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from wait_for import wait_for as _wait_for
from widgetastic.exceptions import NoSuchElementException

from .dropdown import Dropdown, DropdownItemDisabled, DropdownItemNotFound
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion src/widgetastic_patternfly5/components/menus/select.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from wait_for import wait_for as _wait_for
from widgetastic.exceptions import NoSuchElementException
from widgetastic.widget import TextInput

Expand Down Expand Up @@ -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
Comment on lines +224 to +226
_wait_for(
lambda: page.locator(create_css).count() > 0,
timeout=10,
delay=0.2,
)
page.locator(create_css).click()
Comment on lines +224 to +232

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Avoid brittle string parsing of CREATE_ITEM_LOCATOR to derive the CSS selector.

This assumes CREATE_ITEM_LOCATOR is always an XPath with @id='…' in a specific format. Any change (quotes, extra predicates, non-XPath locator) will either raise IndexError or yield a wrong selector.

Instead, either:

  • Use the locator directly via the browser abstraction (e.g. self.root_browser.click(self.CREATE_ITEM_LOCATOR) with waiting), or
  • Locate the element with the existing locator and call get_attribute('id') to derive #id.

That avoids coupling this code to a specific string representation of the locator.

return True
else:
self.item_select(value)
Expand Down
8 changes: 6 additions & 2 deletions src/widgetastic_patternfly5/components/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +124 to +127

def __repr__(self):
return f"{type(self).__name__}({self.ROOT!r})"
Expand Down
5 changes: 4 additions & 1 deletion src/widgetastic_patternfly5/components/slider.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from wait_for import wait_for as _wait_for
from widgetastic.widget import GenericLocatorWidget


Expand Down Expand Up @@ -98,7 +99,9 @@ def fill(self, value):
if self.text == value:
return False
el = self.browser.element(self.INPUT)
Comment on lines 99 to 101
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
18 changes: 15 additions & 3 deletions testing/components/menus/test_dropdown_disabled.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading