diff --git a/src/wabot/page.py b/src/wabot/page.py index 7b2120f..51b89ea 100644 --- a/src/wabot/page.py +++ b/src/wabot/page.py @@ -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) diff --git a/src/wabot/screenshot.py b/src/wabot/screenshot.py new file mode 100644 index 0000000..e7de733 --- /dev/null +++ b/src/wabot/screenshot.py @@ -0,0 +1 @@ +"""Full-page screenshots. Implemented in the screenshot task.""" diff --git a/tests/unit/test_page.py b/tests/unit/test_page.py index 13b16d8..c26b4ca 100644 --- a/tests/unit/test_page.py +++ b/tests/unit/test_page.py @@ -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