Skip to content
Merged
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
1 change: 1 addition & 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 Down
2 changes: 1 addition & 1 deletion src/widgetastic_patternfly5/charts/bullet_chart.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 14 additions & 6 deletions src/widgetastic_patternfly5/components/chip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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",
)

Expand All @@ -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",
)

Expand Down Expand Up @@ -248,7 +256,7 @@ def can_close(self):
return self.close_button.is_displayed

def close(self):
self.close_button.click()
self.browser.element(CATEGORY_CLOSE).dispatch_event("click")

@classmethod
def all(cls, browser):
Expand Down
25 changes: 22 additions & 3 deletions src/widgetastic_patternfly5/components/forms/radio.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,31 @@ 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)
"""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
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
36 changes: 25 additions & 11 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 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
from widgetastic.widget import Widget
Expand Down Expand Up @@ -29,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')]"
)
Expand Down Expand Up @@ -83,12 +87,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=self.WAIT_TIMEOUT, delay=self.WAIT_DELAY)
return self.is_open

def close(self, ignore_nonpresent=False):
Expand Down Expand Up @@ -164,12 +167,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:
Expand Down Expand Up @@ -222,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)
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=self.WAIT_TIMEOUT, delay=self.WAIT_DELAY)
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=self.WAIT_TIMEOUT, delay=self.WAIT_DELAY)
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
Loading
Loading