Skip to content

Commit 540f821

Browse files
mshriverclaude
andcommitted
Updates for PF6 unit tests
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78f6e67 commit 540f821

12 files changed

Lines changed: 177 additions & 50 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,30 @@ jobs:
2121
outputs:
2222
playwright-version: ${{ steps.playwright-version.outputs.version }}
2323
steps:
24-
- name: Checkout
25-
uses: actions/checkout@v4
24+
- uses: actions/checkout@v4
2625

27-
- name: Set up Python
28-
uses: actions/setup-python@v5
26+
- uses: actions/setup-python@v5
2927
with:
3028
python-version: "3.11"
3129

32-
- name: Install Playwright
33-
run: pip install playwright
30+
- name: Install hatch
31+
run: pip install hatch
32+
33+
- name: Cache hatch environment
34+
uses: actions/cache@v4
35+
with:
36+
path: ~/.local/share/hatch
37+
key: hatch-${{ runner.os }}-3.11-${{ hashFiles('pyproject.toml') }}
38+
restore-keys: |
39+
hatch-${{ runner.os }}-3.11-
40+
41+
- name: Create test environment
42+
run: hatch env create test
3443

3544
- name: Get Playwright version
3645
id: playwright-version
37-
run: echo "version=$(pip show playwright | grep Version | cut -d' ' -f2)" >> $GITHUB_OUTPUT
46+
run: |
47+
echo "version=$(hatch run test:python -c 'import playwright; print(playwright.__version__)')" >> $GITHUB_OUTPUT
3848
3949
- name: Cache Playwright browsers
4050
uses: actions/cache@v4
@@ -47,11 +57,10 @@ jobs:
4757
4858
- name: Install browsers
4959
if: steps.playwright-cache.outputs.cache-hit != 'true'
50-
run: |
51-
playwright install chromium firefox
52-
playwright install-deps
60+
run: hatch run test:install-browsers
61+
5362
test:
54-
name: pf-${{ matrix.pf-version }} (🐍 ${{ matrix.python-version }}, ${{ matrix.browser }})
63+
name: pf-v${{ matrix.pf-version }} (🐍 ${{ matrix.python-version }}, ${{ matrix.browser }})
5564
runs-on: ubuntu-latest
5665
needs: setup-browsers
5766
timeout-minutes: 30
@@ -60,23 +69,36 @@ jobs:
6069
matrix:
6170
browser: [chromium, firefox]
6271
python-version: ["3.12", "3.13"]
63-
pf-version: ["v5", "v6"]
64-
# Reduce redundancy: only run coverage for one combination
72+
# Values are the numeric suffix only so they compose directly into
73+
# the hatch script names pf5 / pf6 and the --pf-version=v5 / v6 flag.
74+
pf-version: ["5", "6"]
75+
# Run coverage only for one combination to keep CI lean
6576
include:
6677
- browser: chromium
6778
python-version: "3.13"
68-
pf-version: "v6"
79+
pf-version: "6"
6980
run-coverage: true
7081
exclude: []
7182
steps:
7283
- uses: actions/checkout@v4
7384

7485
- uses: actions/setup-python@v5
7586
with:
76-
python-version: ${{ matrix.python-version }}
87+
python-version: ${{ matrix.python-version }}
88+
89+
- name: Install hatch
90+
run: pip install hatch
91+
92+
- name: Cache hatch environment
93+
uses: actions/cache@v4
94+
with:
95+
path: ~/.local/share/hatch
96+
key: hatch-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}
97+
restore-keys: |
98+
hatch-${{ runner.os }}-${{ matrix.python-version }}-
7799
78-
- name: Install Playwright
79-
run: pip install playwright
100+
- name: Create test environment
101+
run: hatch env create test
80102

81103
- name: Restore Playwright browsers cache
82104
uses: actions/cache/restore@v4
@@ -85,22 +107,22 @@ jobs:
85107
key: playwright-${{ runner.os }}-${{ needs.setup-browsers.outputs.playwright-version }}-browsers
86108
fail-on-cache-miss: true
87109

88-
- name: Install dependencies
89-
run: |
90-
pip install -U pip wheel
91-
pip install -e .[dev]
92-
93110
- name: Test with pytest (with coverage)
94111
if: matrix.run-coverage == true
95112
timeout-minutes: 25
96113
run: |
97-
pytest -v -n 2 --headless --browser=${{ matrix.browser }} --pf-version=${{ matrix.pf-version }} --cov=./src --cov-report=xml --reruns 2 --reruns-delay 5
114+
hatch run test:pf${{ matrix.pf-version }} \
115+
-n 2 --browser=${{ matrix.browser }} \
116+
--reruns 2 --reruns-delay 5 \
117+
--cov=./src --cov-report=xml
98118
99119
- name: Test with pytest (without coverage)
100120
if: matrix.run-coverage != true
101121
timeout-minutes: 25
102122
run: |
103-
pytest -v -n 2 --headless --browser=${{ matrix.browser }} --pf-version=${{ matrix.pf-version }} --reruns 2 --reruns-delay 5
123+
hatch run test:pf${{ matrix.pf-version }} \
124+
-n 2 --browser=${{ matrix.browser }} \
125+
--reruns 2 --reruns-delay 5
104126
105127
- name: Upload coverage to Codecov
106128
if: matrix.run-coverage == true

pyproject.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dev = [
3535
"pytest-rerunfailures",
3636
"pytest-timeout",
3737
"codecov",
38+
"playwright",
3839
]
3940
doc = ["sphinx"]
4041

@@ -52,6 +53,34 @@ packages = ["src/widgetastic_patternfly5"]
5253
source = "vcs"
5354
raw-options.version_scheme = "calver-by-date"
5455

56+
[tool.hatch.envs.test]
57+
features = ["dev"]
58+
59+
[tool.hatch.envs.test.scripts]
60+
# Installs browser binaries and Linux system-level dependencies.
61+
# Run once after setting up the environment: hatch run test:install-browsers
62+
install-browsers = [
63+
"playwright install chromium firefox",
64+
"playwright install-deps",
65+
]
66+
# Run all tests (headless) – PF version defaults to v6 per conftest
67+
all = "pytest -v --headless {args}"
68+
# PF-version-specific convenience scripts used locally and in CI
69+
pf5 = "pytest -v --headless --pf-version=v5 {args}"
70+
pf6 = "pytest -v --headless --pf-version=v6 {args}"
71+
# Headed mode for local interactive debugging
72+
debug = "pytest -v --pf-version=v6 {args}"
73+
74+
[tool.hatch.envs.lint]
75+
dependencies = ["pre-commit"]
76+
77+
[tool.hatch.envs.lint.scripts]
78+
check = "pre-commit run --all-files"
79+
80+
[tool.pytest.ini_options]
81+
testpaths = ["testing"]
82+
timeout = 60
83+
5584
[tool.ruff]
5685
line-length = 100
5786

src/widgetastic_patternfly5/components/chip.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ def can_close(self):
246246
return self.close_button.is_displayed
247247

248248
def close(self):
249-
self.close_button.click()
249+
close_el = self.browser.element(CATEGORY_CLOSE)
250+
close_el.dispatch_event("click")
250251

251252
@classmethod
252253
def all(cls, browser):

src/widgetastic_patternfly5/components/forms/radio.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,27 @@ class Radio(BaseRadio, View):
4141

4242
@property
4343
def selected(self):
44-
return self.radio.selected
44+
return self.browser.is_checked(self.RADIO_LOC)
4545

4646
@property
4747
def disabled(self):
4848
return "pf-m-disabled" in self.browser.classes(self.label)
4949

5050
def fill(self, values):
5151
"""Can only handle `True` to check the radio, nature of individual radio button"""
52-
return self.radio.fill(values)
52+
if values == self.selected:
53+
return False
54+
if values:
55+
el = self.browser.element(self.RADIO_LOC)
56+
el.evaluate(
57+
"e => {"
58+
" const nativeSetter = Object.getOwnPropertyDescriptor("
59+
" window.HTMLInputElement.prototype, 'checked'"
60+
" ).set;"
61+
" nativeSetter.call(e, true);"
62+
" e.dispatchEvent(new Event('click', {bubbles: true}));"
63+
" e.dispatchEvent(new Event('input', {bubbles: true}));"
64+
" e.dispatchEvent(new Event('change', {bubbles: true}));"
65+
"}"
66+
)
67+
return True

src/widgetastic_patternfly5/components/menus/dropdown.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from contextlib import contextmanager
22

33
from cached_property import cached_property
4+
from wait_for import wait_for as _wait_for
45
from widgetastic.exceptions import NoSuchElementException
56
from widgetastic.utils import ParametrizedLocator
67
from widgetastic.widget import Widget
@@ -83,12 +84,11 @@ def open(self):
8384
if self.is_open:
8485
return
8586

86-
# @wait_for_decorator(timeout=3)
87-
# def _click():
88-
# self.browser.click(self.BUTTON_LOCATOR)
89-
# return self.is_open
90-
el = self.browser.element(self.BUTTON_LOCATOR)
91-
self.browser.click(el)
87+
def _click():
88+
self.browser.click(self.BUTTON_LOCATOR)
89+
return self.is_open
90+
91+
_wait_for(_click, timeout=10, delay=0.5)
9292
return self.is_open
9393

9494
def close(self, ignore_nonpresent=False):

src/widgetastic_patternfly5/components/menus/menu.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from wait_for import wait_for as _wait_for
12
from widgetastic.exceptions import NoSuchElementException
23

34
from .dropdown import Dropdown, DropdownItemDisabled, DropdownItemNotFound
@@ -90,6 +91,19 @@ def close(self, ignore_nonpresent=False):
9091

9192
def item_element(self, item, close=True):
9293
"""Returns a WebElement for given item name."""
94+
if self.IS_ALWAYS_OPEN:
95+
browser_els = self.browser.elements(self.ITEMS_LOCATOR)
96+
items_els = browser_els or self.root_browser.elements(self.ITEMS_LOCATOR)
97+
for el in items_els:
98+
if self.browser.text(el).strip() == item:
99+
try:
100+
inp = self.browser.element(parent=el, locator=".//input")
101+
except NoSuchElementException:
102+
inp = el
103+
return inp
104+
raise MenuItemNotFound(
105+
f"Item {item!r} not found in {repr(self)}. Available items: {self.items}"
106+
)
93107
try:
94108
return super().item_element(item, close)
95109
except DropdownItemNotFound:
@@ -153,9 +167,15 @@ def item_select(self, items, close=True):
153167

154168
try:
155169
for item in items:
156-
element = self.item_element(item, close=False)
157-
if not self.browser.is_selected(element):
158-
element.click()
170+
171+
def _try_select():
172+
element = self.item_element(item, close=False)
173+
if not self.browser.is_selected(element):
174+
element.click()
175+
return self.browser.is_selected(element)
176+
return True
177+
178+
_wait_for(_try_select, timeout=10, delay=0.5)
159179
finally:
160180
if close:
161181
self.close()
@@ -172,9 +192,15 @@ def item_deselect(self, items, close=True):
172192

173193
try:
174194
for item in items:
175-
element = self.item_element(item, close=False)
176-
if self.browser.is_selected(element):
177-
element.click()
195+
196+
def _try_deselect():
197+
element = self.item_element(item, close=False)
198+
if self.browser.is_selected(element):
199+
element.click()
200+
return not self.browser.is_selected(element)
201+
return True
202+
203+
_wait_for(_try_deselect, timeout=10, delay=0.5)
178204
finally:
179205
if close:
180206
self.close()
@@ -205,10 +231,9 @@ def read(self):
205231
for el in item_elements:
206232
item = self.browser.text(el)
207233
try:
208-
# get the child element of the label
209-
selected[item] = self.browser.element(
210-
parent=el, locator=".//input"
211-
).is_checked()
234+
inp = self.browser.element(parent=el, locator=".//input")
235+
checked = inp.is_checked()
236+
selected[item] = checked
212237
except NoSuchElementException:
213238
selected[item] = False
214239

src/widgetastic_patternfly5/components/menus/select.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from wait_for import wait_for as _wait_for
12
from widgetastic.exceptions import NoSuchElementException
23
from widgetastic.widget import TextInput
34

@@ -220,7 +221,15 @@ def fill(self, value, create_item=False):
220221

221222
if create_item and value not in self.items:
222223
self.input.fill(value)
223-
self.root_browser.click(self.CREATE_ITEM_LOCATOR)
224+
_id_attr = self.CREATE_ITEM_LOCATOR.split("@id='")[1].rstrip("']")
225+
create_css = "#" + _id_attr
226+
page = self.browser.element(".").page
227+
_wait_for(
228+
lambda: page.locator(create_css).count() > 0,
229+
timeout=10,
230+
delay=0.2,
231+
)
232+
page.locator(create_css).click()
224233
return True
225234
else:
226235
self.item_select(value)

src/widgetastic_patternfly5/components/navigation.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,14 @@ def select(self, *levels, **kwargs):
117117
f"Could not find element: '{self.ITEM_MATCHING.format(quote(level))}'"
118118
)
119119
if "pf-m-expanded" not in li.get_attribute("class").split():
120-
self.browser.click(li)
120+
link_el = self.browser.element(".//*[self::a or self::button]", parent=li)
121+
link_el.dispatch_event("click")
121122
if i == len(levels):
122123
return
123-
current_item = self.browser.element(self.SUB_ITEMS_ROOT, parent=li)
124+
try:
125+
current_item = self.browser.element(self.SUB_ITEMS_ROOT, parent=li)
126+
except NoSuchElementException:
127+
raise
124128

125129
def __repr__(self):
126130
return f"{type(self).__name__}({self.ROOT!r})"

src/widgetastic_patternfly5/components/slider.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from wait_for import wait_for as _wait_for
12
from widgetastic.widget import GenericLocatorWidget
23

34

@@ -98,7 +99,9 @@ def fill(self, value):
9899
if self.text == value:
99100
return False
100101
el = self.browser.element(self.INPUT)
101-
el.press("Control+A")
102+
el.focus()
102103
el.fill(str(value))
104+
el.dispatch_event("change")
103105
el.press("Enter")
106+
_wait_for(lambda: self.text == value, timeout=10, delay=0.2)
104107
return True

testing/components/menus/test_dropdown_disabled.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,25 @@
88

99
TESTING_PAGE_COMPONENT = "components/menus/dropdown/react-templates/simple"
1010

11+
# In PF5 the Dropdown renders a wrapper div (pf-vX-c-dropdown) around the
12+
# MenuToggle button. In PF6 the Dropdown uses Popper inline rendering, so the
13+
# button's parent is a plain wrapper div with no PF class.
14+
#
15+
# The original locator relied on data-ouia-component-id="default-1" which
16+
# PF6 never generates (OUIA IDs are auto-generated, not "default-1").
17+
#
18+
# This locator finds the first MenuToggle button with text "Dropdown" then
19+
# steps up to its parent — the natural ROOT for the Dropdown widget regardless
20+
# of PF version.
21+
_DROPDOWN_LOCATOR = (
22+
".//button[contains(@class, '-c-menu-toggle') and normalize-space(.)='Dropdown'][1]/.."
23+
)
24+
1125

1226
@pytest.fixture
1327
def view(browser):
1428
class TestView(View):
15-
dropdown_custom_locator = Dropdown(
16-
locator=".//button[contains(@data-ouia-component-type, '/MenuToggle') and contains(@data-ouia-component-id, 'default-1')]/parent::div"
17-
)
29+
dropdown_custom_locator = Dropdown(locator=_DROPDOWN_LOCATOR)
1830
disable_checkbox = Checkbox(id="simple-example-disabled-toggle")
1931

2032
view = TestView(browser)

0 commit comments

Comments
 (0)