Skip to content

Commit 811f38a

Browse files
added: scroll to element and wait till element is visible (#11)
* added: ability to scroll to element and wait till element is visible - Updated `scroll_until_element_visible` to first find the elements using locators before passing them to the `scroll` function. - Enhanced scrolling logic to ensure elements are properly located and scrolled into view. chore: updated ruff configuration to exclude docstring checks - Modified `.ruff.toml` to exclude docstring-related linting rules.
1 parent 5e7c097 commit 811f38a

File tree

7 files changed

+149
-83
lines changed

7 files changed

+149
-83
lines changed

.ruff.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ ignore = [
2424
"N801", # Function name should be lowercase
2525
"I001", # Import convention violation
2626
"F631", # Assert should not be used with a literal
27+
"D212", # Multi-line docstring should start on the first line
28+
"D213", # Multi-line docstring should start on the second line
29+
"E501", # Line too long
2730
]
2831

2932
# Regular expression for dummy variables

conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ def driver(request):
6262
except Exception as e:
6363
pytest.fail(f"Failed to initialize driver: {e}")
6464

65-
yield driver
65+
yield event_driver
6666

67-
if driver is not None:
68-
driver.quit()
67+
if event_driver is not None:
68+
event_driver.quit()
6969

7070

7171
# def pytest_runtest_makereport(item, call):

src/locators/locators.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33

44
class Locators:
5-
class main_menu:
6-
TEXT_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Text')
7-
CONTENT_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Content')
8-
VIEWS_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Views')
9-
MENU_ELEMENTS = (AppiumBy.XPATH, '//android.widget.TextView')
10-
11-
class views_menu:
12-
TEXT_FIELDS = (AppiumBy.ACCESSIBILITY_ID, 'TextFields')
13-
14-
class views_fields:
15-
HINT_INPUT = (AppiumBy.ACCESSIBILITY_ID, 'hint')
5+
class main_menu:
6+
TEXT_LINK = (AppiumBy.ACCESSIBILITY_ID, "Text")
7+
CONTENT_LINK = (AppiumBy.ACCESSIBILITY_ID, "Content")
8+
VIEWS_LINK = (AppiumBy.ACCESSIBILITY_ID, "Views")
9+
MENU_ELEMENTS = (AppiumBy.XPATH, "//android.widget.TextView")
10+
11+
class views_menu:
12+
TEXT_FIELDS = (AppiumBy.ACCESSIBILITY_ID, "TextFields")
13+
ANIMATION_LINK = (AppiumBy.ACCESSIBILITY_ID, "Animation")
14+
GALLERY_LINK = (AppiumBy.ACCESSIBILITY_ID, "Gallery")
15+
IMAGE_BUTTON = (AppiumBy.ACCESSIBILITY_ID, "ImageButton")
16+
17+
class views_fields:
18+
HINT_INPUT = (AppiumBy.ACCESSIBILITY_ID, "hint")

src/screens/base_screen.py

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,25 @@
11
import time
2-
from typing import Tuple, Literal, Optional
3-
4-
from selenium.webdriver.common.actions import interaction
5-
from selenium.webdriver.common.actions.action_builder import ActionBuilder
6-
from selenium.webdriver.common.actions.pointer_actions import PointerActions
7-
from selenium.webdriver.common.actions.pointer_input import PointerInput
2+
from typing import Tuple, Literal
83

94
from screens.element_interactor import ElementInteractor
10-
from appium.webdriver.extensions.action_helpers import ActionHelpers, ActionChains
115

126

137
Locator = Tuple[str, str]
8+
type Condition = Literal["clickable", "visible", "present"]
9+
type Direction = Literal["down", "up"]
1410

1511

1612
class Screen(ElementInteractor):
1713
def __init__(self, driver):
1814
super().__init__(driver)
1915

20-
def click(
21-
self,
22-
locator: Locator,
23-
condition: Literal["clickable", "visible", "present"] = "clickable",
24-
):
16+
def click(self, locator: Locator, condition: Condition = "clickable"):
2517
element = self.element(locator, condition=condition)
2618
element.click()
2719

2820
def tap(self, locator: Locator, duration: float = 500, **kwargs):
2921
"""Taps on an element using ActionHelpers.
30-
Taps on an particular place with up to five fingers, holding for a
22+
Taps on a particular place with up to five fingers, holding for a
3123
certain duration
3224
3325
:param locator: locator of an element
@@ -67,12 +59,12 @@ def swipe(
6759

6860
def scroll(
6961
self,
70-
directions: Literal["down", "up"] = "down",
62+
directions: Direction = "down",
7163
start_ratio: float = 0.7,
7264
end_ratio: float = 0.3,
7365
):
7466
"""
75-
Scrolls down the screen with customizable scroll size.
67+
Scrolls down/up the screen with customizable scroll size.
7668
7769
:param directions: up or down:
7870
:param start_ratio: Percentage (0-1) from where the scroll starts
@@ -100,15 +92,39 @@ def scroll(
10092

10193
self.scroll_by_coordinates(start_x, start_y, start_x, end_y)
10294

95+
def scroll_to_element(
96+
self, from_el: Locator, destination_el: Locator, duration: [int] = 500
97+
):
98+
"""Scrolls to the destination element(Both elements must be located(visible)).
99+
100+
:param from_el: Locator of the element to start scrolling from.
101+
:param destination_el: Locator of the target element to scroll to.
102+
:param duration: Optional duration for each scroll.
103+
"""
104+
from_element = self.element(from_el)
105+
to_element = self.element(destination_el)
106+
107+
self.driver.scroll(to_element, from_element, duration=duration)
108+
109+
def scroll_until_element_visible(
110+
self,
111+
destination_el: Locator,
112+
directions: Direction = "down",
113+
start_ratio: float = 0.6,
114+
end_ratio: float = 0.3,
115+
retries: int = 1,
116+
):
117+
while self.is_exist(destination_el, expected=False, n=retries):
118+
self.scroll(
119+
directions=directions, start_ratio=start_ratio, end_ratio=end_ratio
120+
)
121+
103122
def type(self, locator: Locator, text: str):
104123
element = self.element(locator)
105124
element.send_keys(text)
106125

107126
def double_tap(
108-
self,
109-
locator: Locator,
110-
condition: Literal["clickable", "visible", "present"] = "clickable",
111-
**kwargs,
127+
self, locator: Locator, condition: Condition = "clickable", **kwargs
112128
):
113129
"""Double taps on an element."""
114130
try:

src/screens/element_interactor.py

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
from selenium.webdriver.common.actions.action_builder import ActionBuilder
88
from selenium.webdriver.common.actions.pointer_input import PointerInput
99
from selenium.webdriver.remote.webelement import WebElement
10-
from selenium.webdriver.support import expected_conditions as EC
10+
from selenium.webdriver.support import expected_conditions as ec
1111
from selenium.webdriver.support.wait import WebDriverWait
1212
from selenium.common.exceptions import TimeoutException, NoSuchElementException
1313

1414
Locator = Tuple[str, str]
15+
type Condition = Literal["clickable", "visible", "present"]
1516

1617

1718
class WaitType(Enum):
@@ -34,19 +35,20 @@ def __init__(self, driver):
3435
)
3536

3637
def _get_waiter(self, wait_type: Optional[WaitType] = None) -> WebDriverWait:
38+
"""Returns the appropriate waiter based on the given wait_type."""
3739
return self.waiters.get(wait_type, self.waiters[WaitType.DEFAULT])
3840

3941
def wait_for(
4042
self,
4143
locator: Locator,
42-
condition: Literal["clickable", "visible", "present"] = "visible",
44+
condition: Condition = "visible",
4345
waiter: Optional[WebDriverWait] = None,
4446
) -> WebElement:
4547
waiter = waiter or self._get_waiter()
4648
conditions = {
47-
"clickable": EC.element_to_be_clickable(locator),
48-
"visible": EC.visibility_of_element_located(locator),
49-
"present": EC.presence_of_element_located(locator),
49+
"clickable": ec.element_to_be_clickable(locator),
50+
"visible": ec.visibility_of_element_located(locator),
51+
"present": ec.presence_of_element_located(locator),
5052
}
5153
if condition not in conditions:
5254
raise ValueError(f"Unknown condition: {condition}")
@@ -61,7 +63,7 @@ def element(
6163
self,
6264
locator: Locator,
6365
n: int = 3,
64-
condition: Literal["clickable", "visible", "present"] = "visible",
66+
condition: Condition = "visible",
6567
wait_type: Optional[WaitType] = WaitType.DEFAULT,
6668
):
6769
for attempt in range(1, n + 1):
@@ -80,7 +82,7 @@ def elements(
8082
self,
8183
locator: Locator,
8284
n: int = 3,
83-
condition: Literal["clickable", "visible", "present"] = "visible",
85+
condition: Condition = "visible",
8486
wait_type: Optional[WaitType] = WaitType.DEFAULT,
8587
) -> List[WebElement]:
8688
for attempt in range(1, n + 1):
@@ -100,7 +102,7 @@ def is_displayed(
100102
locator: Locator,
101103
expected: bool = True,
102104
n: int = 3,
103-
condition: Literal["clickable", "visible", "present"] = "visible",
105+
condition: Condition = "visible",
104106
wait_type: Optional[WaitType] = None,
105107
) -> None:
106108
wait_type = wait_type or WaitType.DEFAULT
@@ -125,30 +127,55 @@ def is_exist(
125127
locator: Locator,
126128
expected: bool = True,
127129
n: int = 3,
128-
condition: Literal["clickable", "visible", "present"] = "visible",
129-
wait_type: Optional[WaitType] = WaitType.DEFAULT,
130+
condition: Condition = "visible",
131+
wait_type: Optional[WaitType] = WaitType.SHORT,
132+
retry_delay: float = 0.5,
130133
) -> bool:
134+
"""
135+
Checks if an element exists on the screen within a specified number of retries.
136+
137+
:param retry_delay: delay between retry
138+
:param locator: The locator tuple (strategy, value) used to find the element.
139+
:param expected: Determines whether the element should exist (True) or not (False).
140+
:param n: The number of attempts to check for the element before returning a result.
141+
:param condition: The condition to check for the element's existence.
142+
- "clickable": Ensures the element is interactable.
143+
- "visible": Ensures the element is visible on the page.
144+
- "present": Ensures the element exists in the DOM (even if not visible).
145+
:param wait_type: Specifies the wait strategy (default is WaitType.DEFAULT).
146+
:return: True if the element matches the expected state, False otherwise.
147+
:rtype: bool
148+
149+
150+
**Usage Example:**
151+
152+
screen.is_exist(("id", "login-button"))
153+
True
154+
155+
screen.is_exist(("id", "error-popup"), expected=False)
156+
True
157+
"""
131158
for _ in range(n):
132159
try:
133160
element = self.element(
134161
locator, n=1, condition=condition, wait_type=wait_type
135162
)
136163
return element.is_displayed() == expected
137-
except NoSuchElementException:
164+
except (NoSuchElementException, TimeoutException):
138165
if not expected:
139166
return True
140-
except Exception:
141-
pass
142-
time.sleep(0.5)
167+
except Exception as e:
168+
print(f"Unexpected error in is_exist: {e}")
169+
time.sleep(retry_delay)
143170
return not expected
144-
171+
145172
def scroll_by_coordinates(
146-
self,
147-
start_x: int,
148-
start_y: int,
149-
end_x: int,
150-
end_y: int,
151-
duration: Optional[int] = None,
173+
self,
174+
start_x: int,
175+
start_y: int,
176+
end_x: int,
177+
end_y: int,
178+
duration: Optional[int] = None,
152179
):
153180
"""Scrolls from one set of coordinates to another.
154181
@@ -161,17 +188,18 @@ def scroll_by_coordinates(
161188
"""
162189
if duration is None:
163190
duration = 700
164-
191+
165192
touch_input = PointerInput(interaction.POINTER_TOUCH, "touch")
166193
actions = ActionChains(self.driver)
167-
168-
actions.w3c_actions = ActionBuilder(self.driver, mouse = touch_input)
194+
195+
actions.w3c_actions = ActionBuilder(self.driver, mouse=touch_input)
169196
actions.w3c_actions.pointer_action.move_to_location(start_x, start_y)
170197
actions.w3c_actions.pointer_action.pointer_down()
171-
actions.w3c_actions = ActionBuilder(self.driver, mouse=touch_input, duration=duration)
172-
198+
actions.w3c_actions = ActionBuilder(
199+
self.driver, mouse=touch_input, duration=duration
200+
)
201+
173202
actions.w3c_actions.pointer_action.move_to_location(end_x, end_y)
174203
actions.w3c_actions.pointer_action.release()
175-
204+
176205
actions.perform()
177-

src/screens/main_screen/main_screen.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,29 @@
55

66

77
class MainScreen(Screen):
8-
9-
def __init__(self, driver):
10-
super().__init__(driver)
11-
self.locators = Locators()
12-
13-
def click_on_text_link(self):
14-
self.click(locator = self.locators.main_menu.TEXT_LINK)
15-
16-
def tap_on_text_link(self):
17-
self.tap(locator = self.locators.main_menu.TEXT_LINK)
18-
19-
def scroll_view_by_coordinates(self, direction: Literal['down', 'up'] = 'down'):
20-
self.tap(locator = self.locators.main_menu.VIEWS_LINK)
21-
self.scroll(directions = direction)
22-
23-
8+
def __init__(self, driver):
9+
super().__init__(driver)
10+
self.locators = Locators()
11+
12+
def click_on_text_link(self):
13+
self.click(locator=self.locators.main_menu.TEXT_LINK)
14+
15+
def tap_on_text_link(self):
16+
self.tap(locator=self.locators.main_menu.TEXT_LINK)
17+
18+
def scroll_view_by_coordinates(self, direction: Literal["down", "up"] = "down"):
19+
self.tap(locator=self.locators.main_menu.VIEWS_LINK)
20+
self.scroll(directions=direction)
21+
22+
def scroll_to_image_button(self):
23+
self.tap(locator=self.locators.main_menu.VIEWS_LINK)
24+
self.scroll_to_element(
25+
from_el=self.locators.views_menu.ANIMATION_LINK,
26+
destination_el=self.locators.views_menu.IMAGE_BUTTON,
27+
)
28+
29+
def scroll_until_text_field_visible(self):
30+
self.tap(locator=self.locators.main_menu.VIEWS_LINK)
31+
self.scroll_until_element_visible(
32+
destination_el=self.locators.views_menu.TEXT_FIELDS
33+
)

tests/test_p1/test_actions.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,24 @@
22
from screens.main_screen.main_screen import MainScreen
33

44

5-
class TestClick:
5+
class TestBaseActions:
66
@pytest.fixture(autouse=True)
77
def setup(self, driver) -> None:
88
"""Setup common objects for tests after address is set."""
99
self.main_screen = MainScreen(driver)
10-
10+
1111
def test_click(self, setup):
1212
self.main_screen.click_on_text_link()
13-
13+
1414
def test_tap(self, setup):
1515
self.main_screen.tap_on_text_link()
16-
16+
1717
def test_scroll_by_coordinates(self, setup):
18-
self.main_screen.scroll_view_by_coordinates(direction = "down")
19-
self.main_screen.scroll('up')
18+
self.main_screen.scroll_view_by_coordinates(direction="down")
19+
self.main_screen.scroll("up")
20+
21+
def test_sroll_to_element(self, setup):
22+
self.main_screen.scroll_to_image_button()
23+
24+
def test_scroll_util_visible(self, setup):
25+
self.main_screen.scroll_until_text_field_visible()

0 commit comments

Comments
 (0)