mirror of
https://git.zavage.net/Zavage-Software/wabot.git
synced 2026-07-21 13:06:08 -06:00
feat: Browser facade with working refuse-after-exception guard
This commit is contained in:
parent
cafacee2b9
commit
4734915475
90
src/wabot/browser.py
Normal file
90
src/wabot/browser.py
Normal file
@ -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)
|
||||
99
tests/unit/test_browser.py
Normal file
99
tests/unit/test_browser.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user