mirror of
https://git.zavage.net/Zavage-Software/wabot.git
synced 2026-07-21 13:06:08 -06:00
feat: Page interaction helpers (paced clicks, forms, alerts, waits)
This commit is contained in:
parent
fbb7d4114a
commit
c99b9509f4
@ -20,8 +20,17 @@ cannot be found.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from selenium.common.exceptions import NoSuchElementException
|
||||
from selenium.common.exceptions import (
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
WebDriverException,
|
||||
)
|
||||
from selenium.webdriver import ActionChains
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
from .fields import CheckField, NullField, SelectField, TextField
|
||||
|
||||
@ -96,3 +105,176 @@ class Page:
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.get_proxy(key)
|
||||
|
||||
# ---- clicking -----------------------------------------------------
|
||||
|
||||
def click(self, el, clickable_timeout: float = 10.0) -> bool:
|
||||
"""Click, at a pacing-chosen offset when the policy asks for one."""
|
||||
if not el:
|
||||
LOGGER.warning("refusing to click null element")
|
||||
return False
|
||||
try:
|
||||
size = el.size
|
||||
except StaleElementReferenceException:
|
||||
LOGGER.error("failed to click element: stale reference")
|
||||
return False
|
||||
if not self._wait_clickable(el, clickable_timeout):
|
||||
return False
|
||||
offset = self.pacing.click_offset(size["width"], size["height"])
|
||||
try:
|
||||
if offset is None:
|
||||
el.click()
|
||||
else:
|
||||
x, y = offset # measured from element center (selenium 4)
|
||||
ActionChains(self.driver).move_to_element_with_offset(el, x, y).click().perform()
|
||||
return True
|
||||
except WebDriverException as ex:
|
||||
LOGGER.error("click failed: %s", ex)
|
||||
return False
|
||||
|
||||
def _wait_clickable(self, el, timeout: float) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
try:
|
||||
if el.is_displayed() and el.is_enabled():
|
||||
return True
|
||||
except StaleElementReferenceException:
|
||||
return False
|
||||
if time.monotonic() >= deadline:
|
||||
LOGGER.error("element never became clickable")
|
||||
return False
|
||||
time.sleep(0.2)
|
||||
|
||||
def click_and_go(self, el) -> bool:
|
||||
"""Click, accept any alert, then wait for the page to change."""
|
||||
if not self.click(el):
|
||||
return False
|
||||
self.accept_alert()
|
||||
return self._wait_for_element_to_go_stale(el)
|
||||
|
||||
def _wait_for_element_to_go_stale(self, el) -> bool:
|
||||
try:
|
||||
WebDriverWait(self.driver, PAGE_LOAD_TIMEOUT).until(
|
||||
lambda _driver: self.is_element_stale(el)
|
||||
)
|
||||
return True
|
||||
except TimeoutException:
|
||||
LOGGER.error("timed out waiting for page load")
|
||||
return False
|
||||
|
||||
def is_element_stale(self, el) -> bool:
|
||||
try:
|
||||
_ = el.tag_name
|
||||
return False
|
||||
except StaleElementReferenceException:
|
||||
return True
|
||||
|
||||
# ---- alerts -------------------------------------------------------
|
||||
|
||||
def accept_alert(self, accept: bool = True, timeout: float = ALERT_TIMEOUT):
|
||||
"""Accept (or dismiss) a JS alert if one appears within ``timeout``.
|
||||
|
||||
Returns the alert text, or False if no alert appeared. There is a
|
||||
timeout penalty when no alert exists.
|
||||
"""
|
||||
try:
|
||||
WebDriverWait(self.driver, timeout).until(EC.alert_is_present())
|
||||
except TimeoutException:
|
||||
LOGGER.debug("no js alert present")
|
||||
return False
|
||||
alert = self.driver.switch_to.alert
|
||||
text = alert.text
|
||||
if accept:
|
||||
alert.accept()
|
||||
else:
|
||||
alert.dismiss()
|
||||
LOGGER.debug("handled js alert: %s", text)
|
||||
return text
|
||||
|
||||
# ---- form helpers -------------------------------------------------
|
||||
|
||||
def get_el_value(self, el):
|
||||
if not el:
|
||||
return None
|
||||
return el.get_attribute("value")
|
||||
|
||||
def get_el_text(self, el):
|
||||
if not el:
|
||||
return None
|
||||
return el.text
|
||||
|
||||
def set_el_value(self, el, value, slow_type: bool = False) -> bool:
|
||||
if not el:
|
||||
return False
|
||||
el.clear()
|
||||
if value is None:
|
||||
return True
|
||||
try:
|
||||
if slow_type:
|
||||
for char in str(value):
|
||||
el.send_keys(char)
|
||||
time.sleep(self.pacing.delay("text") / 10.0)
|
||||
else:
|
||||
el.send_keys(value)
|
||||
except WebDriverException as ex:
|
||||
LOGGER.error("failed to send keys, element in unknown state: %s", ex)
|
||||
return False
|
||||
actual = self.get_el_value(el)
|
||||
if str(actual) != str(value):
|
||||
LOGGER.error("field value mismatch: wanted %r, field has %r", value, actual)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_select_value(self, select):
|
||||
if not select:
|
||||
LOGGER.error("tried to get select value of null element")
|
||||
return None
|
||||
try:
|
||||
return select.first_selected_option.get_attribute("value")
|
||||
except NoSuchElementException:
|
||||
return None
|
||||
|
||||
def set_select_value(self, select, value=None, text=None) -> bool:
|
||||
if not select:
|
||||
return False
|
||||
try:
|
||||
if value is not None:
|
||||
select.select_by_value(str(value))
|
||||
return True
|
||||
if text is not None:
|
||||
select.select_by_visible_text(text)
|
||||
return True
|
||||
except (NoSuchElementException, WebDriverException) as ex:
|
||||
LOGGER.error("failed to set select: %s", ex)
|
||||
return False
|
||||
|
||||
def set_checkbox(self, el, checked: bool) -> bool:
|
||||
if not el:
|
||||
return False
|
||||
if not el.is_enabled():
|
||||
return False
|
||||
if el.is_selected() != checked:
|
||||
self.click(el)
|
||||
return True
|
||||
|
||||
def get_checkbox_value(self, el, ignore_disabled: bool = False):
|
||||
"""True if (enabled and) checked; None for a null element."""
|
||||
if not el:
|
||||
return None
|
||||
return (ignore_disabled or el.is_enabled()) and el.is_selected()
|
||||
|
||||
def get_el_identifier(self, el):
|
||||
"""A quick human-readable identifier for logging."""
|
||||
for attr in ("id", "name", "class"):
|
||||
value = el.get_attribute(attr)
|
||||
if value:
|
||||
return value
|
||||
return el.tag_name
|
||||
|
||||
# ---- screenshots ----------------------------------------------------
|
||||
|
||||
def save_screenshot(self, filename: str) -> bool:
|
||||
from . import screenshot as screenshot_mod
|
||||
|
||||
LOGGER.info("saving full-page screenshot: %s", filename)
|
||||
return screenshot_mod.save_full_page(self.driver, filename)
|
||||
|
||||
1
src/wabot/screenshot.py
Normal file
1
src/wabot/screenshot.py
Normal file
@ -0,0 +1 @@
|
||||
"""Full-page screenshots. Implemented in the screenshot task."""
|
||||
@ -2,8 +2,9 @@ from unittest.mock import MagicMock
|
||||
|
||||
from selenium.common.exceptions import NoSuchElementException
|
||||
|
||||
import wabot.page as page_mod
|
||||
from wabot.fields import CheckField, NullField, SelectField, TextField
|
||||
from wabot.pacing import NoPacing
|
||||
from wabot.pacing import HumanPacing, NoPacing
|
||||
from wabot.page import Page
|
||||
|
||||
|
||||
@ -121,3 +122,104 @@ class TestGetProxy:
|
||||
class TestVerify:
|
||||
def test_default_verify_is_true(self):
|
||||
assert BasePage(make_browser()).verify() is True
|
||||
|
||||
|
||||
class PlainPage(Page):
|
||||
elements = {}
|
||||
|
||||
|
||||
def make_element(width=100, height=30, displayed=True, enabled=True):
|
||||
el = MagicMock(name="element")
|
||||
el.size = {"width": width, "height": height}
|
||||
el.is_displayed.return_value = displayed
|
||||
el.is_enabled.return_value = enabled
|
||||
return el
|
||||
|
||||
|
||||
class TestClick:
|
||||
def test_refuses_null_element(self):
|
||||
assert PlainPage(make_browser()).click(None) is False
|
||||
assert PlainPage(make_browser()).click(NullField(name="x")) is False
|
||||
|
||||
def test_plain_click_when_pacing_gives_no_offset(self):
|
||||
page = PlainPage(make_browser()) # NoPacing -> click_offset None
|
||||
el = make_element()
|
||||
assert page.click(el) is True
|
||||
el.click.assert_called_once_with()
|
||||
|
||||
def test_offset_click_uses_action_chains(self, monkeypatch):
|
||||
browser = make_browser()
|
||||
browser.pacing = HumanPacing()
|
||||
chains = MagicMock(name="ActionChains")
|
||||
monkeypatch.setattr(page_mod, "ActionChains", chains)
|
||||
page = PlainPage(browser)
|
||||
el = make_element(width=100, height=40)
|
||||
assert page.click(el) is True
|
||||
chains.assert_called_once_with(browser.driver)
|
||||
args = chains.return_value.move_to_element_with_offset.call_args.args
|
||||
assert args[0] is el
|
||||
# offsets are center-relative and must stay inside the element
|
||||
assert -49 <= args[1] <= 49 and -19 <= args[2] <= 19
|
||||
chains.return_value.move_to_element_with_offset.return_value.click.return_value.perform.assert_called_once()
|
||||
|
||||
def test_never_clickable_element_fails(self):
|
||||
page = PlainPage(make_browser())
|
||||
el = make_element(displayed=False)
|
||||
assert page.click(el, clickable_timeout=0.3) is False
|
||||
el.click.assert_not_called()
|
||||
|
||||
|
||||
class TestFormHelpers:
|
||||
def test_set_el_value_clears_types_and_verifies(self):
|
||||
page = PlainPage(make_browser())
|
||||
el = make_element()
|
||||
el.get_attribute.return_value = "mathew"
|
||||
assert page.set_el_value(el, "mathew") is True
|
||||
el.clear.assert_called_once_with()
|
||||
el.send_keys.assert_called_once_with("mathew")
|
||||
|
||||
def test_set_el_value_returns_false_on_mismatch(self):
|
||||
page = PlainPage(make_browser())
|
||||
el = make_element()
|
||||
el.get_attribute.return_value = "wrong"
|
||||
assert page.set_el_value(el, "mathew") is False
|
||||
|
||||
def test_set_el_value_none_just_clears(self):
|
||||
page = PlainPage(make_browser())
|
||||
el = make_element()
|
||||
assert page.set_el_value(el, None) is True
|
||||
el.clear.assert_called_once_with()
|
||||
el.send_keys.assert_not_called()
|
||||
|
||||
def test_get_el_value_and_text_handle_null(self):
|
||||
page = PlainPage(make_browser())
|
||||
assert page.get_el_value(None) is None
|
||||
assert page.get_el_text(None) is None
|
||||
|
||||
def test_set_checkbox_clicks_only_when_state_differs(self):
|
||||
page = PlainPage(make_browser())
|
||||
el = make_element()
|
||||
el.is_selected.return_value = False
|
||||
page.set_checkbox(el, True)
|
||||
el.click.assert_called_once()
|
||||
el.click.reset_mock()
|
||||
el.is_selected.return_value = True
|
||||
page.set_checkbox(el, True)
|
||||
el.click.assert_not_called()
|
||||
|
||||
|
||||
class TestStaleness:
|
||||
def test_fresh_element_is_not_stale(self):
|
||||
page = PlainPage(make_browser())
|
||||
assert page.is_element_stale(make_element()) is False
|
||||
|
||||
def test_stale_element_is_detected(self):
|
||||
from selenium.common.exceptions import StaleElementReferenceException
|
||||
|
||||
class StaleEl: # NOT a MagicMock: mutating type(mock) would poison every mock
|
||||
@property
|
||||
def tag_name(self):
|
||||
raise StaleElementReferenceException("stale")
|
||||
|
||||
page = PlainPage(make_browser())
|
||||
assert page.is_element_stale(StaleEl()) is True
|
||||
|
||||
Loading…
Reference in New Issue
Block a user