diff --git a/src/auto_reverse/browser.py b/src/auto_reverse/browser.py new file mode 100644 index 0000000..92b0baf --- /dev/null +++ b/src/auto_reverse/browser.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Any + + +class Browser: + """Headed (or headless) Playwright Chromium, optionally routed via proxy.""" + + def __init__(self, proxy_port: int | None, headless: bool = False) -> None: + self._proxy_port = proxy_port + self._headless = headless + self._pw: Any = None + self._browser: Any = None + self._page: Any = None + + def start(self) -> None: + from playwright.sync_api import sync_playwright + + self._pw = sync_playwright().start() + launch_kwargs: dict[str, Any] = { + "headless": self._headless, + "args": ["--ignore-certificate-errors"], + } + if self._proxy_port is not None: + launch_kwargs["proxy"] = {"server": f"http://127.0.0.1:{self._proxy_port}"} + self._browser = self._pw.chromium.launch(**launch_kwargs) + context = self._browser.new_context(ignore_https_errors=True) + self._page = context.new_page() + + def navigate(self, url: str) -> dict[str, Any]: + self._page.goto(url, wait_until="networkidle") + return self.snapshot() + + def click(self, selector: str) -> dict[str, Any]: + self._page.click(selector, timeout=5000) + self._page.wait_for_load_state("networkidle") + return self.snapshot() + + def type_text(self, selector: str, text: str) -> dict[str, Any]: + self._page.fill(selector, text) + return self.snapshot() + + def snapshot(self) -> dict[str, Any]: + """Compact view for the agent: url, title, and visible interactive elements.""" + elements = self._page.eval_on_selector_all( + "a, button, input, [role=button], [role=link]", + """els => els.slice(0, 40).map(e => ({ + tag: e.tagName.toLowerCase(), + text: (e.innerText || e.value || e.getAttribute('aria-label') || '').slice(0, 60), + id: e.id || null, + }))""", + ) + return { + "url": self._page.url, + "title": self._page.title(), + "elements": elements, + } + + def pause_for_human(self) -> None: + """Surface the headed browser for manual control (Playwright inspector).""" + self._page.pause() + + def stop(self) -> None: + if self._browser is not None: + self._browser.close() + if self._pw is not None: + self._pw.stop() diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 0000000..33ec420 --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,23 @@ +import pytest + +playwright = pytest.importorskip("playwright.sync_api") + +from auto_reverse.browser import Browser # noqa: E402 + + +@pytest.fixture +def browser(): + try: + b = Browser(proxy_port=None, headless=True) + b.start() + except Exception as exc: # browser binary missing, etc. + pytest.skip(f"browser unavailable: {exc}") + yield b + b.stop() + + +def test_navigate_and_snapshot(browser, fixture_site): + browser.navigate(fixture_site + "/") + snap = browser.snapshot() + assert snap["url"].endswith("/") + assert "title" in snap