mirror of
https://github.com/pocketpaw/pocketpaw.git
synced 2026-05-20 08:49:49 +00:00
Ruff auto-formatting: - Apply ruff format across 42 files (import sorting, line length, etc.) Test fixes (13 failures resolved): - Skip Unix file permission tests on Windows (4 tests) - Fix OAuth scope test using a now-valid scope name - Fix screenshot test path assertion for Windows - Fix launcher updater tests for Windows venv layout - Fix media downloader hash collision by adding randomness - Fix concurrent memory access PermissionError on Windows - Fix activity feed sort stability with sequence counter - Fix Sarvam STT encoding (use UTF-8 for Hindi text output) - Fix event loop error in task persistence test (asyncio.run) Source fixes: - Add UTF-8 encoding to STT transcript file writes - Add retry logic for file_store atomic replace on Windows - Add insertion sequence to activity feed for stable ordering - Add randomness to media filename hash for uniqueness
396 lines
13 KiB
Python
396 lines
13 KiB
Python
# Browser driver tests
|
|
# Changes: Initial creation with comprehensive Playwright driver tests
|
|
# Fixed mocking for async_playwright().start() pattern
|
|
"""Tests for browser driver module."""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from pocketpaw.browser.driver import BrowserDriver
|
|
from pocketpaw.browser.snapshot import RefMap
|
|
|
|
|
|
class TestBrowserDriverInit:
|
|
"""Tests for BrowserDriver initialization."""
|
|
|
|
def test_driver_init(self):
|
|
"""Should initialize with headless mode by default."""
|
|
driver = BrowserDriver()
|
|
assert driver.headless is True
|
|
assert driver._browser is None
|
|
assert driver._page is None
|
|
|
|
def test_driver_init_headful(self):
|
|
"""Should support headful mode."""
|
|
driver = BrowserDriver(headless=False)
|
|
assert driver.headless is False
|
|
|
|
|
|
class TestBrowserDriverContextManager:
|
|
"""Tests for async context manager support."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_context_manager_launches_browser(self):
|
|
"""Should launch browser on enter and close on exit."""
|
|
driver = BrowserDriver()
|
|
|
|
with (
|
|
patch.object(driver, "launch", new_callable=AsyncMock) as mock_launch,
|
|
patch.object(driver, "close", new_callable=AsyncMock) as mock_close,
|
|
):
|
|
async with driver:
|
|
mock_launch.assert_called_once()
|
|
mock_close.assert_called_once()
|
|
|
|
|
|
class TestBrowserDriverLaunch:
|
|
"""Tests for browser launch functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_launch_creates_browser_and_page(self):
|
|
"""Should create browser and page on launch."""
|
|
driver = BrowserDriver()
|
|
|
|
# Mock playwright - it uses async_playwright().start()
|
|
mock_browser = AsyncMock()
|
|
mock_page = AsyncMock()
|
|
mock_context = AsyncMock()
|
|
mock_context.new_page = AsyncMock(return_value=mock_page)
|
|
mock_browser.new_context = AsyncMock(return_value=mock_context)
|
|
|
|
mock_playwright = MagicMock()
|
|
mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)
|
|
|
|
# async_playwright() returns something with start() method
|
|
mock_pw_cm = MagicMock()
|
|
mock_pw_cm.start = AsyncMock(return_value=mock_playwright)
|
|
|
|
with patch("playwright.async_api.async_playwright", return_value=mock_pw_cm):
|
|
await driver.launch()
|
|
|
|
assert driver._browser is not None
|
|
assert driver._page is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_launch_sets_viewport(self):
|
|
"""Should set reasonable viewport size."""
|
|
driver = BrowserDriver()
|
|
|
|
mock_browser = AsyncMock()
|
|
mock_page = AsyncMock()
|
|
mock_context = AsyncMock()
|
|
mock_context.new_page = AsyncMock(return_value=mock_page)
|
|
mock_browser.new_context = AsyncMock(return_value=mock_context)
|
|
|
|
mock_playwright = MagicMock()
|
|
mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)
|
|
|
|
mock_pw_cm = MagicMock()
|
|
mock_pw_cm.start = AsyncMock(return_value=mock_playwright)
|
|
|
|
with patch("playwright.async_api.async_playwright", return_value=mock_pw_cm):
|
|
await driver.launch()
|
|
|
|
# Should call new_context with viewport
|
|
mock_browser.new_context.assert_called_once()
|
|
call_kwargs = mock_browser.new_context.call_args.kwargs
|
|
assert "viewport" in call_kwargs
|
|
|
|
|
|
class TestBrowserDriverNavigation:
|
|
"""Tests for navigation functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_navigate_goes_to_url(self):
|
|
"""Should navigate to the given URL."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._page.goto = AsyncMock()
|
|
driver._page.title = AsyncMock(return_value="Test Page")
|
|
driver._page.url = "https://example.com"
|
|
driver._page.accessibility.snapshot = AsyncMock(
|
|
return_value={"role": "WebArea", "name": "Test Page", "children": []}
|
|
)
|
|
|
|
result = await driver.navigate("https://example.com")
|
|
|
|
driver._page.goto.assert_called_once_with(
|
|
"https://example.com", wait_until="domcontentloaded"
|
|
)
|
|
assert "Page: Test Page" in result.snapshot
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_navigate_returns_snapshot_and_refmap(self):
|
|
"""Should return snapshot text and refmap."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._page.goto = AsyncMock()
|
|
driver._page.title = AsyncMock(return_value="Example")
|
|
driver._page.url = "https://example.com"
|
|
driver._page.accessibility.snapshot = AsyncMock(
|
|
return_value={
|
|
"role": "WebArea",
|
|
"name": "Example",
|
|
"children": [{"role": "button", "name": "Click Me"}],
|
|
}
|
|
)
|
|
|
|
result = await driver.navigate("https://example.com")
|
|
|
|
assert isinstance(result.snapshot, str)
|
|
assert isinstance(result.refmap, RefMap)
|
|
assert "[ref=1]" in result.snapshot
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_navigate_requires_page(self):
|
|
"""Should raise error if browser not launched."""
|
|
driver = BrowserDriver()
|
|
|
|
with pytest.raises(RuntimeError, match="Browser not launched"):
|
|
await driver.navigate("https://example.com")
|
|
|
|
|
|
class TestBrowserDriverClick:
|
|
"""Tests for click functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_click_by_ref(self):
|
|
"""Should click element by reference number."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._refmap = RefMap()
|
|
driver._refmap.add('role=button[name="Submit"]')
|
|
|
|
driver._page.locator = MagicMock()
|
|
mock_locator = AsyncMock()
|
|
driver._page.locator.return_value = mock_locator
|
|
driver._page.title = AsyncMock(return_value="Form")
|
|
driver._page.url = "https://example.com/form"
|
|
driver._page.accessibility.snapshot = AsyncMock(
|
|
return_value={"role": "WebArea", "name": "Form", "children": []}
|
|
)
|
|
|
|
result = await driver.click(ref=1)
|
|
|
|
driver._page.locator.assert_called_once_with('role=button[name="Submit"]')
|
|
mock_locator.click.assert_called_once()
|
|
assert result.snapshot is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_click_invalid_ref(self):
|
|
"""Should raise error for invalid ref."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._refmap = RefMap()
|
|
|
|
with pytest.raises(ValueError, match="Invalid ref"):
|
|
await driver.click(ref=999)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_click_requires_page(self):
|
|
"""Should raise error if browser not launched."""
|
|
driver = BrowserDriver()
|
|
|
|
with pytest.raises(RuntimeError, match="Browser not launched"):
|
|
await driver.click(ref=1)
|
|
|
|
|
|
class TestBrowserDriverType:
|
|
"""Tests for text input functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_type_text(self):
|
|
"""Should type text into element by ref."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._refmap = RefMap()
|
|
driver._refmap.add('role=textbox[name="Username"]')
|
|
|
|
driver._page.locator = MagicMock()
|
|
mock_locator = AsyncMock()
|
|
driver._page.locator.return_value = mock_locator
|
|
|
|
await driver.type_text(ref=1, text="testuser")
|
|
|
|
driver._page.locator.assert_called_once_with('role=textbox[name="Username"]')
|
|
mock_locator.fill.assert_called_once_with("testuser")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_type_clears_field_first(self):
|
|
"""Should clear field before typing (fill behavior)."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._refmap = RefMap()
|
|
driver._refmap.add('role=textbox[name="Email"]')
|
|
|
|
driver._page.locator = MagicMock()
|
|
mock_locator = AsyncMock()
|
|
driver._page.locator.return_value = mock_locator
|
|
|
|
await driver.type_text(ref=1, text="new@email.com")
|
|
|
|
# fill() replaces content (clears first)
|
|
mock_locator.fill.assert_called_once_with("new@email.com")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_type_invalid_ref(self):
|
|
"""Should raise error for invalid ref."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._refmap = RefMap()
|
|
|
|
with pytest.raises(ValueError, match="Invalid ref"):
|
|
await driver.type_text(ref=999, text="hello")
|
|
|
|
|
|
class TestBrowserDriverScroll:
|
|
"""Tests for scroll functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scroll_down(self):
|
|
"""Should scroll down."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._page.title = AsyncMock(return_value="Page")
|
|
driver._page.url = "https://example.com"
|
|
driver._page.accessibility.snapshot = AsyncMock(
|
|
return_value={"role": "WebArea", "name": "Page", "children": []}
|
|
)
|
|
|
|
result = await driver.scroll(direction="down")
|
|
|
|
driver._page.evaluate.assert_called()
|
|
assert result.snapshot is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scroll_up(self):
|
|
"""Should scroll up."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._page.title = AsyncMock(return_value="Page")
|
|
driver._page.url = "https://example.com"
|
|
driver._page.accessibility.snapshot = AsyncMock(
|
|
return_value={"role": "WebArea", "name": "Page", "children": []}
|
|
)
|
|
|
|
await driver.scroll(direction="up")
|
|
|
|
driver._page.evaluate.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scroll_invalid_direction(self):
|
|
"""Should raise error for invalid direction."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
|
|
with pytest.raises(ValueError, match="Invalid direction"):
|
|
await driver.scroll(direction="sideways")
|
|
|
|
|
|
class TestBrowserDriverSnapshot:
|
|
"""Tests for snapshot functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_returns_current_state(self):
|
|
"""Should return current page snapshot."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
driver._page.title = AsyncMock(return_value="Current Page")
|
|
driver._page.url = "https://example.com/current"
|
|
driver._page.accessibility.snapshot = AsyncMock(
|
|
return_value={
|
|
"role": "WebArea",
|
|
"name": "Current Page",
|
|
"children": [{"role": "heading", "name": "Hello", "level": 1}],
|
|
}
|
|
)
|
|
|
|
result = await driver.snapshot()
|
|
|
|
assert "Page: Current Page" in result.snapshot
|
|
assert "URL: https://example.com/current" in result.snapshot
|
|
assert "heading" in result.snapshot
|
|
assert "Hello" in result.snapshot
|
|
|
|
|
|
class TestBrowserDriverScreenshot:
|
|
"""Tests for screenshot functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screenshot_saves_file(self):
|
|
"""Should save screenshot to file."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
|
|
test_path = str(Path("/tmp/test.png").resolve())
|
|
path = await driver.screenshot(test_path)
|
|
|
|
driver._page.screenshot.assert_called_once()
|
|
assert path == test_path
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screenshot_default_path(self):
|
|
"""Should use default path if not provided."""
|
|
driver = BrowserDriver()
|
|
driver._page = AsyncMock()
|
|
|
|
with patch("pocketpaw.browser.driver.datetime") as mock_dt:
|
|
mock_dt.now.return_value.strftime.return_value = "20240101_120000"
|
|
path = await driver.screenshot()
|
|
|
|
assert "screenshot_20240101_120000.png" in path
|
|
|
|
|
|
class TestBrowserDriverClose:
|
|
"""Tests for browser close functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_browser(self):
|
|
"""Should close browser and cleanup."""
|
|
driver = BrowserDriver()
|
|
mock_browser = AsyncMock()
|
|
driver._browser = mock_browser
|
|
driver._page = AsyncMock()
|
|
|
|
await driver.close()
|
|
|
|
mock_browser.close.assert_called_once()
|
|
assert driver._browser is None
|
|
assert driver._page is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_when_not_launched(self):
|
|
"""Should handle close when browser not launched."""
|
|
driver = BrowserDriver()
|
|
|
|
# Should not raise
|
|
await driver.close()
|
|
|
|
|
|
class TestBrowserDriverProperties:
|
|
"""Tests for driver properties."""
|
|
|
|
def test_is_launched(self):
|
|
"""Should report launch status."""
|
|
driver = BrowserDriver()
|
|
assert driver.is_launched is False
|
|
|
|
driver._browser = MagicMock()
|
|
driver._page = MagicMock()
|
|
assert driver.is_launched is True
|
|
|
|
def test_current_url(self):
|
|
"""Should return current URL."""
|
|
driver = BrowserDriver()
|
|
driver._page = MagicMock()
|
|
driver._page.url = "https://example.com/test"
|
|
|
|
assert driver.current_url == "https://example.com/test"
|
|
|
|
def test_current_url_none_when_not_launched(self):
|
|
"""Should return None when not launched."""
|
|
driver = BrowserDriver()
|
|
assert driver.current_url is None
|