feat: Page interaction helpers (paced clicks, forms, alerts, waits)

This commit is contained in:
Mathew Sir Guest the best 2026-07-07 23:01:09 -06:00
parent fbb7d4114a
commit c99b9509f4
3 changed files with 287 additions and 2 deletions

@ -20,8 +20,17 @@ cannot be found.
from __future__ import annotations from __future__ import annotations
import logging 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 from .fields import CheckField, NullField, SelectField, TextField
@ -96,3 +105,176 @@ class Page:
def __getitem__(self, key): def __getitem__(self, key):
return self.get_proxy(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

@ -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 from selenium.common.exceptions import NoSuchElementException
import wabot.page as page_mod
from wabot.fields import CheckField, NullField, SelectField, TextField from wabot.fields import CheckField, NullField, SelectField, TextField
from wabot.pacing import NoPacing from wabot.pacing import HumanPacing, NoPacing
from wabot.page import Page from wabot.page import Page
@ -121,3 +122,104 @@ class TestGetProxy:
class TestVerify: class TestVerify:
def test_default_verify_is_true(self): def test_default_verify_is_true(self):
assert BasePage(make_browser()).verify() is True 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