Skip to content

Commit 7532295

Browse files
authored
Merge pull request #234 from autoscrape-labs/codex/use-commitizen-style-for-issue-#222
feat: add wait_until method to WebElement
2 parents 187acae + d60fc25 commit 7532295

File tree

5 files changed

+213
-7
lines changed

5 files changed

+213
-7
lines changed

docs/deep-dive/webelement-domain.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ classDiagram
7373
+get_attribute(name: str)
7474
+set_input_files(files: list[str])
7575
+scroll_into_view()
76+
+wait_until()
7677
+take_screenshot(path: str)
7778
+text
7879
+inner_html
@@ -373,8 +374,19 @@ is_visible = await element._is_element_visible()
373374

374375
# Check if element is the topmost at its position
375376
is_on_top = await element._is_element_on_top()
377+
378+
# Check if element can be interacted with
379+
is_interactable = await element._is_element_interactable()
380+
381+
# Wait until the element is ready for interaction
382+
await element.wait_until(is_visible=True, is_interactable=True, timeout=5)
383+
384+
# Raises ``WaitElementTimeout`` if the conditions aren't met in time.
376385
```
377386

387+
If both ``is_visible`` and ``is_interactable`` are set to ``True``, the element
388+
must satisfy **both** conditions to proceed.
389+
378390
These verifications are crucial for reliable automation, ensuring that elements can be interacted with before attempting operations.
379391

380392
## Position and Scrolling

docs/zh/deep-dive/webelement-domain.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ classDiagram
7474
+get_attribute(name: str)
7575
+set_input_files(files: list[str])
7676
+scroll_into_view()
77+
+wait_until()
7778
+take_screenshot(path: str)
7879
+text
7980
+inner_html
@@ -366,7 +367,7 @@ async def _execute_script(
366367

367368
## 元素状态验证
368369

369-
WebElement 提供了检查元素可见性和可交互性的方法:
370+
WebElement 提供了检查元素可见性和可交互性的方法,并可等待这些条件满足
370371

371372

372373
```python
@@ -375,8 +376,18 @@ is_visible = await element._is_element_visible()
375376

376377
# Check if element is the topmost at its position
377378
is_on_top = await element._is_element_on_top()
379+
380+
# Check if element can be interacted with
381+
is_interactable = await element._is_element_interactable()
382+
383+
# Wait until the element is ready for interaction
384+
await element.wait_until(is_visible=True, is_interactable=True, timeout=5)
385+
386+
# 未在限定时间内满足条件时会抛出 ``WaitElementTimeout``。
378387
```
379388

389+
如果同时传入 ``is_visible````is_interactable``,必须同时满足这两个条件。
390+
380391
这些验证对于实现可靠的自动化至关重要,可在尝试操作前确保元素可被交互。
381392

382393
## 位置与滚动

pydoll/constants.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,39 @@ class Scripts:
2525
ELEMENT_ON_TOP = """
2626
function() {
2727
const rect = this.getBoundingClientRect();
28-
const elementFromPoint = document.elementFromPoint(
29-
rect.x + rect.width / 2,
30-
rect.y + rect.height / 2
31-
);
32-
return elementFromPoint === this;
28+
const x = rect.x + rect.width / 2;
29+
const y = rect.y + rect.height / 2;
30+
const elementFromPoint = document.elementFromPoint(x, y);
31+
if (!elementFromPoint) {
32+
return false;
33+
}
34+
return elementFromPoint === this || this.contains(elementFromPoint);
35+
}
36+
"""
37+
38+
ELEMENT_INTERACTIVE = """
39+
function() {
40+
const style = window.getComputedStyle(this);
41+
const rect = this.getBoundingClientRect();
42+
if (
43+
rect.width <= 0 ||
44+
rect.height <= 0 ||
45+
style.visibility === 'hidden' ||
46+
style.display === 'none' ||
47+
style.pointerEvents === 'none'
48+
) {
49+
return false;
50+
}
51+
const x = rect.x + rect.width / 2;
52+
const y = rect.y + rect.height / 2;
53+
const elementFromPoint = document.elementFromPoint(x, y);
54+
if (!elementFromPoint || (elementFromPoint !== this && !this.contains(elementFromPoint))) {
55+
return false;
56+
}
57+
if (this.disabled) {
58+
return false;
59+
}
60+
return true;
3361
}
3462
"""
3563

pydoll/elements/web_element.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
ElementNotFound,
2222
ElementNotInteractable,
2323
ElementNotVisible,
24+
WaitElementTimeout,
2425
)
2526
from pydoll.protocol.dom.methods import (
2627
GetBoxModelResponse,
@@ -180,6 +181,50 @@ async def scroll_into_view(self):
180181
command = DomCommands.scroll_into_view_if_needed(object_id=self._object_id)
181182
await self._execute_command(command)
182183

184+
async def wait_until(
185+
self,
186+
*,
187+
is_visible: bool = False,
188+
is_interactable: bool = False,
189+
timeout: int = 0,
190+
):
191+
"""Wait for element to meet specified conditions.
192+
193+
Raises:
194+
ValueError: If neither ``is_visible`` nor ``is_interactable`` is True.
195+
WaitElementTimeout: If the condition is not met within ``timeout``.
196+
"""
197+
checks_map = [
198+
(is_visible, self._is_element_visible),
199+
(is_interactable, self._is_element_interactable),
200+
]
201+
checks = [func for flag, func in checks_map if flag]
202+
if not checks:
203+
raise ValueError(
204+
'At least one of is_visible or is_interactable must be True'
205+
)
206+
207+
condition_parts = []
208+
if is_visible:
209+
condition_parts.append('visible')
210+
if is_interactable:
211+
condition_parts.append('interactable')
212+
condition_msg = ' and '.join(condition_parts)
213+
214+
loop = asyncio.get_event_loop()
215+
start_time = loop.time()
216+
while True:
217+
results = await asyncio.gather(*(check() for check in checks))
218+
if all(results):
219+
return
220+
221+
if timeout and loop.time() - start_time > timeout:
222+
raise WaitElementTimeout(
223+
f'Timed out waiting for element to become {condition_msg}'
224+
)
225+
226+
await asyncio.sleep(0.5)
227+
183228
async def click_using_js(self):
184229
"""
185230
Click element using JavaScript click() method.
@@ -375,6 +420,13 @@ async def _is_element_on_top(self):
375420
result = await self._execute_script(Scripts.ELEMENT_ON_TOP, return_by_value=True)
376421
return result['result']['result']['value']
377422

423+
async def _is_element_interactable(self):
424+
"""Check if element is interactable based on visibility and position."""
425+
result = await self._execute_script(
426+
Scripts.ELEMENT_INTERACTIVE, return_by_value=True
427+
)
428+
return result['result']['result']['value']
429+
378430
async def _execute_script(self, script: str, return_by_value: bool = False):
379431
"""
380432
Execute JavaScript in element context.

tests/test_web_element.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,10 +719,113 @@ async def test_is_element_on_top_false(self, web_element):
719719
web_element._execute_script = AsyncMock(
720720
return_value={'result': {'result': {'value': False}}}
721721
)
722-
722+
723723
result = await web_element._is_element_on_top()
724724
assert result is False
725725

726+
@pytest.mark.asyncio
727+
async def test_is_element_interactable_true(self, web_element):
728+
"""Test _is_element_interactable returns True."""
729+
web_element._execute_script = AsyncMock(
730+
return_value={'result': {'result': {'value': True}}}
731+
)
732+
733+
result = await web_element._is_element_interactable()
734+
assert result is True
735+
736+
@pytest.mark.asyncio
737+
async def test_is_element_interactable_false(self, web_element):
738+
"""Test _is_element_interactable returns False."""
739+
web_element._execute_script = AsyncMock(
740+
return_value={'result': {'result': {'value': False}}}
741+
)
742+
743+
result = await web_element._is_element_interactable()
744+
assert result is False
745+
746+
747+
class TestWebElementWaitUntil:
748+
"""Test wait_until method."""
749+
750+
@pytest.mark.asyncio
751+
async def test_wait_until_visible_success(self, web_element):
752+
"""Test wait_until succeeds when element becomes visible."""
753+
web_element._is_element_visible = AsyncMock(side_effect=[False, True])
754+
755+
with patch('asyncio.sleep') as mock_sleep, \
756+
patch('asyncio.get_event_loop') as mock_loop:
757+
mock_loop.return_value.time.side_effect = [0, 0.5]
758+
759+
await web_element.wait_until(is_visible=True, timeout=2)
760+
761+
assert web_element._is_element_visible.call_count == 2
762+
mock_sleep.assert_called_once_with(0.5)
763+
764+
@pytest.mark.asyncio
765+
async def test_wait_until_visible_timeout(self, web_element):
766+
"""Test wait_until raises WaitElementTimeout when visibility not met."""
767+
web_element._is_element_visible = AsyncMock(return_value=False)
768+
769+
with patch('asyncio.sleep') as mock_sleep, \
770+
patch('asyncio.get_event_loop') as mock_loop:
771+
mock_loop.return_value.time.side_effect = [0, 0.5, 1.0, 1.5, 2.1]
772+
773+
with pytest.raises(
774+
WaitElementTimeout, match='element to become visible'
775+
):
776+
await web_element.wait_until(is_visible=True, timeout=2)
777+
778+
assert mock_sleep.call_count == 3
779+
780+
@pytest.mark.asyncio
781+
async def test_wait_until_interactable_success(self, web_element):
782+
"""Test wait_until succeeds when element becomes interactable."""
783+
web_element._is_element_interactable = AsyncMock(return_value=True)
784+
785+
await web_element.wait_until(is_interactable=True, timeout=1)
786+
787+
web_element._is_element_interactable.assert_called_once()
788+
789+
@pytest.mark.asyncio
790+
async def test_wait_until_interactable_timeout(self, web_element):
791+
"""Test wait_until raises WaitElementTimeout when not interactable."""
792+
web_element._is_element_interactable = AsyncMock(return_value=False)
793+
794+
with patch('asyncio.sleep') as mock_sleep, \
795+
patch('asyncio.get_event_loop') as mock_loop:
796+
mock_loop.return_value.time.side_effect = [0, 0.5, 1.1]
797+
798+
with pytest.raises(
799+
WaitElementTimeout, match='element to become interactable'
800+
):
801+
await web_element.wait_until(is_interactable=True, timeout=1)
802+
803+
mock_sleep.assert_called_once_with(0.5)
804+
805+
@pytest.mark.asyncio
806+
async def test_wait_until_visible_and_interactable(self, web_element):
807+
"""Test wait_until requires both conditions when both are True."""
808+
web_element._is_element_visible = AsyncMock(side_effect=[False, True])
809+
web_element._is_element_interactable = AsyncMock(side_effect=[False, True])
810+
811+
with patch('asyncio.sleep') as mock_sleep, \
812+
patch('asyncio.get_event_loop') as mock_loop:
813+
mock_loop.return_value.time.side_effect = [0, 0.5, 1.0]
814+
815+
await web_element.wait_until(
816+
is_visible=True, is_interactable=True, timeout=2
817+
)
818+
819+
assert web_element._is_element_visible.call_count == 2
820+
assert web_element._is_element_interactable.call_count == 2
821+
mock_sleep.assert_called_once_with(0.5)
822+
823+
@pytest.mark.asyncio
824+
async def test_wait_until_no_conditions(self, web_element):
825+
"""Test wait_until raises ValueError when no condition specified."""
826+
with pytest.raises(ValueError):
827+
await web_element.wait_until()
828+
726829

727830
class TestWebElementUtilityMethods:
728831
"""Test utility and helper methods."""

0 commit comments

Comments
 (0)