diff --git a/src/wabot/browser.py b/src/wabot/browser.py new file mode 100644 index 0000000..17f47aa --- /dev/null +++ b/src/wabot/browser.py @@ -0,0 +1,90 @@ +"""Browser: the facade consumers drive. + +Holds the webdriver plus a *current page* object. Unknown attribute +access is delegated to the current page (``bot.login()`` invokes +``bot.page.login()``); ``bot[key]`` resolves elements on it. Any +``perform()`` failure flips ``good = False``, after which page actions +are refused until ``reset()`` — "log and refuse, don't crash". +""" + +from __future__ import annotations + +import logging + +from selenium.common.exceptions import WebDriverException + +from .pacing import HumanPacing + +LOGGER = logging.getLogger("wabot") + +# attributes that must never be delegated to the page +_OWN_ATTRS = frozenset( + {"driver", "pacing", "session_name", "good", "page", "_store"} +) + + +def _refused(*_args, **_kwargs): + return None + + +class Browser: + def __init__(self, driver, *, pacing=None, session_name=None, store=None): + self.driver = driver + self.pacing = pacing if pacing is not None else HumanPacing() + self.session_name = session_name + self._store = store + self.good = True + self.page = None + + def set_page(self, page_cls) -> bool: + """Instantiate ``page_cls`` and make it current if its verify() passes.""" + page = page_cls(self) + if not page.verify(): + LOGGER.error("failed to verify page: %s", page_cls.__name__) + return False + self.page = page + return True + + def __getattr__(self, name): + if name in _OWN_ATTRS or name.startswith("__"): + raise AttributeError(name) + if self.page is None: + raise AttributeError( + f"{name!r}: no current page — call set_page() first" + ) + if not self.good: + LOGGER.warning("broken state — refusing page action %r (call reset())", name) + return _refused + return getattr(self.page, name) + + def __getitem__(self, key): + return self.page[key] + + def perform(self, method: str, *args, **kwargs): + """Invoke a page method, trapping failures instead of raising.""" + if not self.good: + LOGGER.warning("broken state — refusing %r (call reset())", method) + return None + try: + fn = getattr(self.page, method) + except AttributeError: + LOGGER.error("page %s has no action %r", type(self.page).__name__, method) + return None + try: + return fn(*args, **kwargs) + except WebDriverException: + LOGGER.exception("page action %r failed; flipping good=False", method) + self.good = False + return None + + def reset(self) -> None: + """Clear the broken-state flag after an exception.""" + self.good = True + + def quit(self) -> None: + """Quit the browser; forget the saved session if there is one.""" + try: + self.driver.quit() + finally: + if self._store is not None and self.session_name: + self._store.remove(self.session_name) diff --git a/tests/unit/test_browser.py b/tests/unit/test_browser.py new file mode 100644 index 0000000..31cc48d --- /dev/null +++ b/tests/unit/test_browser.py @@ -0,0 +1,99 @@ +from unittest.mock import MagicMock + +import pytest +from selenium.common.exceptions import WebDriverException + +from wabot.browser import Browser +from wabot.pacing import HumanPacing, NoPacing +from wabot.page import Page + + +class Login(Page): + elements = {"username": ("el", ("id", "username"))} + + def do_login(self): + return "logged-in" + + def explode(self): + raise WebDriverException("browser gone") + + +class Unverifiable(Page): + def verify(self): + return False + + +@pytest.fixture +def bot(): + return Browser(MagicMock(name="driver"), pacing=NoPacing()) + + +class TestConstruction: + def test_good_flag_starts_true(self, bot): + assert bot.good is True + + def test_default_pacing_is_human(self): + assert isinstance(Browser(MagicMock()).pacing, HumanPacing) + + +class TestSetPage: + def test_sets_and_returns_true(self, bot): + assert bot.set_page(Login) is True + assert isinstance(bot.page, Login) + + def test_failed_verify_refuses_switch(self, bot): + bot.set_page(Login) + assert bot.set_page(Unverifiable) is False + assert isinstance(bot.page, Login) # unchanged + + +class TestDelegation: + def test_getattr_delegates_to_page(self, bot): + bot.set_page(Login) + assert bot.do_login() == "logged-in" + + def test_getitem_delegates_to_page(self, bot): + bot.set_page(Login) + el = bot["username"] + assert el.name == "username" + + def test_getattr_without_page_raises(self, bot): + with pytest.raises(AttributeError): + _ = bot.do_login + + +class TestPerform: + def test_returns_method_result(self, bot): + bot.set_page(Login) + assert bot.perform("do_login") == "logged-in" + + def test_missing_method_returns_none(self, bot): + bot.set_page(Login) + assert bot.perform("nope") is None + + def test_webdriver_exception_flips_good(self, bot): + bot.set_page(Login) + assert bot.perform("explode") is None + assert bot.good is False + + def test_broken_state_refuses_actions_until_reset(self, bot): + bot.set_page(Login) + bot.perform("explode") + assert bot.perform("do_login") is None # refused + assert bot.do_login() is None # __getattr__ path also refused + bot.reset() + assert bot.perform("do_login") == "logged-in" + + +class TestQuit: + def test_quit_quits_driver_and_removes_session(self): + driver, store = MagicMock(), MagicMock() + bot = Browser(driver, session_name="s1", store=store) + bot.quit() + driver.quit.assert_called_once_with() + store.remove.assert_called_once_with("s1") + + def test_quit_without_session_only_quits(self): + driver = MagicMock() + Browser(driver).quit() + driver.quit.assert_called_once_with()