diff --git a/src/wabot/__init__.py b/src/wabot/__init__.py index cdc5338..49b51d9 100644 --- a/src/wabot/__init__.py +++ b/src/wabot/__init__.py @@ -1 +1,147 @@ -"""wabot — stateful Selenium browser automation with reattachable sessions.""" +"""wabot — stateful Selenium browser automation with reattachable sessions. + +Quickstart:: + + import wabot + + bot = wabot.browser(session="scraper1", browser="firefox") + # ... process exits; the browser stays open ... + bot = wabot.browser(session="scraper1", browser="firefox") # reattaches +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from selenium import webdriver +from selenium.common.exceptions import WebDriverException + +from .browser import Browser +from .fields import CheckField, NullField, PageObject, SelectField, TextField +from .hosts import ( + ExternalServer, + ManagedService, + attach, + build_options, + normalize_browser, + service_alive, + stop_service, +) +from .pacing import HumanPacing, NoPacing +from .page import Page +from .sessions import SessionRecord, SessionStore + +__all__ = [ + "Browser", + "CheckField", + "HumanPacing", + "NoPacing", + "NullField", + "Page", + "PageObject", + "SelectField", + "SessionRecord", + "SessionStore", + "TextField", + "browser", + "destroy", + "sessions", +] + +LOGGER = logging.getLogger("wabot") + + +def _new_remote(url: str, options): + return webdriver.Remote(command_executor=url, options=options) + + +def _new_local(browser_name: str, options): + if browser_name == "chromium": + return webdriver.Chrome(options=options) + return webdriver.Firefox(options=options) + + +def browser( + session: str | None = None, + browser: str = "firefox", + host: str | None = None, + *, + headless: bool = False, + user_agent: str | None = None, + pacing=None, + store: SessionStore | None = None, +) -> Browser: + """Create a Browser, reattaching to a saved session when one exists. + + Args: + session: Persistence name. None (default) = ephemeral: the browser + is not saved and (without ``host``) dies with this process. + browser: "firefox" or "chromium" ("chrome" is an alias). + host: URL of an external WebDriver server (Selenium Grid or a bare + driver). Without it, persistent sessions get a wabot-managed + detached driver service. + headless: Run the browser headless. + user_agent: Override the browser's user agent (default: browser's own). + pacing: A pacing policy; defaults to HumanPacing (stealth delays). + store: SessionStore override, mainly for tests. + """ + browser_name = normalize_browser(browser) + options = build_options(browser_name, headless=headless, user_agent=user_agent) + + if session is None: + if host: + return Browser(_new_remote(host, options), pacing=pacing) + return Browser(_new_local(browser_name, options), pacing=pacing) + + store = store if store is not None else SessionStore() + record = store.get(session) + if record is not None: + if service_alive(record.executor_url): + driver = attach(record.executor_url, record.session_id, record.browser) + if driver is not None: + LOGGER.info("reattached to session %r", session) + return Browser(driver, pacing=pacing, session_name=session, store=store) + LOGGER.warning("saved session %r is dead; creating a fresh browser", session) + store.remove(session) + + host_obj = ExternalServer(host) if host else ManagedService(browser_name) + url = host_obj.ensure_running() + driver = _new_remote(url, options) + store.save( + SessionRecord( + name=session, + executor_url=url, + session_id=driver.session_id, + browser=browser_name, + created_at=datetime.now(timezone.utc).isoformat(), + service_pid=getattr(host_obj, "pid", None), + service_port=getattr(host_obj, "port", None), + ) + ) + LOGGER.info("created persistent session %r on %s", session, url) + return Browser(driver, pacing=pacing, session_name=session, store=store) + + +def sessions(store: SessionStore | None = None) -> list[str]: + """Names of saved (non-stale) sessions.""" + return (store if store is not None else SessionStore()).names() + + +def destroy(name: str, store: SessionStore | None = None) -> bool: + """Quit a saved session's browser, stop its managed service, forget it.""" + store = store if store is not None else SessionStore() + record = store.get(name) + if record is None: + return False + if service_alive(record.executor_url): + driver = attach(record.executor_url, record.session_id, record.browser) + if driver is not None: + try: + driver.quit() + except WebDriverException as ex: + LOGGER.warning("quit failed while destroying %r: %s", name, ex) + if record.service_pid: + stop_service(record.service_pid) + store.remove(name) + return True diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000..fd87afc --- /dev/null +++ b/tests/unit/test_api.py @@ -0,0 +1,143 @@ +from unittest.mock import MagicMock + +import pytest + +import wabot +from wabot.sessions import SessionRecord, SessionStore + + +@pytest.fixture +def store(tmp_path): + return SessionStore(path=tmp_path / "sessions.json") + + +@pytest.fixture +def fake_selenium(monkeypatch): + """Patch every constructor that would talk to a real browser.""" + fakes = MagicMock(name="fakes") + fakes.Remote.return_value.session_id = "new-session-id" + monkeypatch.setattr(wabot, "_new_remote", fakes.Remote) + monkeypatch.setattr(wabot, "_new_local", fakes.Local) + monkeypatch.setattr(wabot, "attach", fakes.attach) + monkeypatch.setattr(wabot, "service_alive", fakes.service_alive) + fakes.managed = MagicMock(name="ManagedService_instance") + fakes.managed.ensure_running.return_value = "http://127.0.0.1:7777" + fakes.managed.pid, fakes.managed.port = 4321, 7777 + monkeypatch.setattr(wabot, "ManagedService", MagicMock(return_value=fakes.managed)) + return fakes + + +class TestEphemeral: + def test_no_session_no_host_uses_local_driver(self, fake_selenium, store): + bot = wabot.browser(store=store) + fake_selenium.Local.assert_called_once() + assert bot.driver is fake_selenium.Local.return_value + assert store.names() == [] # nothing persisted + + def test_no_session_with_host_uses_remote_without_saving(self, fake_selenium, store): + wabot.browser(host="http://grid:4444", store=store) + fake_selenium.Remote.assert_called_once() + assert fake_selenium.Remote.call_args.args[0] == "http://grid:4444" + assert store.names() == [] + + +class TestPersistentCreate: + def test_managed_service_created_and_recorded(self, fake_selenium, store): + bot = wabot.browser(session="s1", browser="chromium", store=store) + record = store.get("s1") + assert record == SessionRecord( + name="s1", + executor_url="http://127.0.0.1:7777", + session_id="new-session-id", + browser="chromium", + created_at=record.created_at, + service_pid=4321, + service_port=7777, + ) + assert bot.session_name == "s1" + + def test_external_host_records_no_pid(self, fake_selenium, store, monkeypatch): + external = MagicMock() + external.ensure_running.return_value = "http://grid:4444" + del external.pid # ExternalServer has no pid/port attributes + del external.port + monkeypatch.setattr(wabot, "ExternalServer", MagicMock(return_value=external)) + wabot.browser(session="s1", host="http://grid:4444", store=store) + record = store.get("s1") + assert record.executor_url == "http://grid:4444" + assert record.service_pid is None and record.service_port is None + + +class TestReattach: + def make_record(self, store): + from datetime import datetime, timezone + + store.save( + SessionRecord( + name="s1", + executor_url="http://127.0.0.1:7777", + session_id="old-session", + browser="chromium", + created_at=datetime.now(timezone.utc).isoformat(), + ) + ) + + def test_live_record_is_reattached(self, fake_selenium, store): + self.make_record(store) + fake_selenium.service_alive.return_value = True + bot = wabot.browser(session="s1", store=store) + fake_selenium.attach.assert_called_once_with( + "http://127.0.0.1:7777", "old-session", "chromium" + ) + assert bot.driver is fake_selenium.attach.return_value + fake_selenium.Remote.assert_not_called() + + def test_dead_record_falls_through_to_fresh_creation(self, fake_selenium, store): + self.make_record(store) + fake_selenium.service_alive.return_value = True + fake_selenium.attach.return_value = None # session gone + wabot.browser(session="s1", store=store) + fake_selenium.Remote.assert_called_once() + assert store.get("s1").session_id == "new-session-id" + + def test_dead_server_skips_attach_entirely(self, fake_selenium, store): + self.make_record(store) + fake_selenium.service_alive.return_value = False + wabot.browser(session="s1", store=store) + fake_selenium.attach.assert_not_called() + fake_selenium.Remote.assert_called_once() + + +class TestHousekeeping: + def test_sessions_lists_names(self, store): + assert wabot.sessions(store=store) == [] + + def test_destroy_quits_kills_and_removes(self, fake_selenium, store, monkeypatch): + from datetime import datetime, timezone + + stop = MagicMock() + monkeypatch.setattr(wabot, "stop_service", stop) + store.save( + SessionRecord( + name="s1", + executor_url="http://127.0.0.1:7777", + session_id="old-session", + browser="chromium", + created_at=datetime.now(timezone.utc).isoformat(), + service_pid=4321, + ) + ) + fake_selenium.service_alive.return_value = True + assert wabot.destroy("s1", store=store) is True + fake_selenium.attach.return_value.quit.assert_called_once_with() + stop.assert_called_once_with(4321) + assert store.get("s1") is None + + def test_destroy_missing_returns_false(self, store): + assert wabot.destroy("nope", store=store) is False + + +class TestPublicSurface: + def test_all_exports_exist(self): + for name in wabot.__all__: + assert hasattr(wabot, name), name diff --git a/tests/unit/test_browser.py b/tests/unit/test_browser.py index 7b71428..eae72e9 100644 --- a/tests/unit/test_browser.py +++ b/tests/unit/test_browser.py @@ -124,7 +124,15 @@ class TestQuit: driver.quit.assert_called_once_with() def test_quit_stops_managed_service(self, monkeypatch): - import wabot.browser as browser_mod + import sys + + # NOTE(Task 12 wiring fix): `import wabot.browser as browser_mod` would + # resolve to `wabot.browser` the FUNCTION (the public API added in + # Task 12 shadows the submodule attribute of the same name on the + # `wabot` package once `wabot/__init__.py` defines `def browser(...)`). + # sys.modules keeps the real submodule reachable regardless of what + # the package's own `browser` attribute is rebound to. + browser_mod = sys.modules["wabot.browser"] stopped = [] monkeypatch.setattr(browser_mod, "stop_service", lambda pid: stopped.append(pid)) @@ -136,7 +144,10 @@ class TestQuit: store.remove.assert_called_once_with("s1") def test_quit_removes_record_even_if_driver_quit_raises(self, monkeypatch): - import wabot.browser as browser_mod + import sys + + # see NOTE in test_quit_stops_managed_service above + browser_mod = sys.modules["wabot.browser"] monkeypatch.setattr(browser_mod, "stop_service", lambda pid: None) driver, store = MagicMock(), MagicMock()