"""
Tests for browser_tools module.
"""
import pytest
import asyncio
import base64
from unittest.mock import AsyncMock, MagicMock, patch
from typing import Dict, Any, cast
from src.percepta_mcp.tools.browser_tools import BrowserAutomation
from src.percepta_mcp.config import Settings, BrowserConfig
from tests.patches.async_mocks import PlaywrightMock, AsyncContextManagerMock
@pytest.fixture
def settings():
"""Create test settings."""
return Settings(
browser=BrowserConfig(
headless=True,
timeout=30000,
viewport_width=1920,
viewport_height=1080,
user_agent="test-agent"
)
)
@pytest.fixture
def mock_browser():
"""Create mock Playwright browser."""
mock_browser = AsyncMock()
mock_context = AsyncMock()
mock_page = AsyncMock()
# Configure mocks
mock_browser.new_context.return_value = mock_context
mock_context.new_page.return_value = mock_page
mock_page.url = "https://example.com"
mock_page.title.return_value = "Test Page"
mock_page.viewport_size = {"width": 1920, "height": 1080}
return {
"browser": mock_browser,
"context": mock_context,
"page": mock_page
}
@pytest.fixture
def mock_playwright(mock_browser: Dict[str, Any]) -> PlaywrightMock:
"""Create mock Playwright instance."""
mock_playwright = PlaywrightMock()
mock_playwright.chromium.launch.return_value = mock_browser["browser"]
return mock_playwright
class TestBrowserAutomation:
"""Test BrowserAutomation class."""
def setup_mocked_browser(self, browser_automation: BrowserAutomation, mock_playwright: PlaywrightMock, mock_browser: Dict[str, Any]) -> None:
"""Setup mocked browser components."""
async def mock_ensure_browser():
# 使用类型转换绕过类型检查
browser_automation.playwright = cast(Any, mock_playwright)
browser_automation.browser = mock_browser["browser"]
browser_automation.context = mock_browser["context"]
browser_automation.page = mock_browser["page"]
# 确保页面对象有我们需要的方法和属性
if not hasattr(mock_browser["page"], "url"):
# 使用 __dict__ 直接设置属性绕过类型检查
mock_browser["page"].__dict__["url"] = "https://example.com"
browser_automation._ensure_browser = mock_ensure_browser
@pytest.mark.asyncio
async def test_init(self, settings):
"""Test BrowserAutomation initialization."""
browser_automation = BrowserAutomation(settings)
assert browser_automation.settings == settings
assert browser_automation.browser is None
assert browser_automation.context is None
assert browser_automation.page is None
assert browser_automation.playwright is None
@pytest.mark.asyncio
async def test_ensure_browser_first_time(self, settings, mock_playwright, mock_browser):
"""Test browser initialization on first call."""
browser_automation = BrowserAutomation(settings)
# 使用 setup_mocked_browser 方法并实际调用 _ensure_browser
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
# 实际调用,这会触发我们的模拟实现
await browser_automation._ensure_browser()
# 验证组件是否正确设置
assert browser_automation.playwright is not None
assert browser_automation.browser is not None
assert browser_automation.context is not None
assert browser_automation.page is not None
@pytest.mark.asyncio
async def test_ensure_browser_already_initialized(self, settings, mock_browser):
"""Test that browser is not re-initialized if already set."""
browser_automation = BrowserAutomation(settings)
browser_automation.browser = mock_browser["browser"]
browser_automation.context = mock_browser["context"]
browser_automation.page = mock_browser["page"]
with patch('src.percepta_mcp.tools.browser_tools.async_playwright') as mock_ap:
await browser_automation._ensure_browser()
# Should not start playwright again
mock_ap.assert_not_called()
@pytest.mark.asyncio
async def test_navigate_success(self, settings, mock_playwright, mock_browser):
"""Test successful navigation."""
browser_automation = BrowserAutomation(settings)
# Mock _ensure_browser to avoid real browser startup
async def mock_ensure_browser():
browser_automation.playwright = mock_playwright
browser_automation.browser = mock_browser["browser"]
browser_automation.context = mock_browser["context"]
browser_automation.page = mock_browser["page"]
browser_automation._ensure_browser = mock_ensure_browser
# Mock response
mock_response = AsyncMock()
mock_response.status = 200
mock_browser["page"].goto.return_value = mock_response
mock_browser["page"].title.return_value = "Test Page"
mock_browser["page"].url = "https://example.com"
result = await browser_automation.navigate("https://example.com")
# Verify navigation
mock_browser["page"].goto.assert_called_once_with(
"https://example.com",
wait_until="networkidle",
timeout=30000
)
# Verify result
assert result["success"] is True
assert result["url"] == "https://example.com"
assert result["title"] == "Test Page"
assert result["status"] == 200
@pytest.mark.asyncio
async def test_navigate_error(self, settings, mock_playwright, mock_browser):
"""Test navigation error handling."""
browser_automation = BrowserAutomation(settings)
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
# Mock navigation error
mock_browser["page"].goto.side_effect = Exception("Navigation failed")
result = await browser_automation.navigate("https://invalid-url")
# Verify error result
assert result["success"] is False
assert "Navigation failed" in result["error"]
@pytest.mark.asyncio
async def test_click_success(self, settings, mock_playwright, mock_browser):
"""Test successful element click."""
browser_automation = BrowserAutomation(settings)
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.click("#button")
# Verify click
mock_browser["page"].wait_for_selector.assert_called_once_with("#button", timeout=30000)
mock_browser["page"].click.assert_called_once_with("#button")
# Verify result
assert result["success"] is True
assert result["selector"] == "#button"
@pytest.mark.asyncio
async def test_click_error(self, settings, mock_playwright, mock_browser):
"""Test click error handling."""
browser_automation = BrowserAutomation(settings)
# Mock click error
mock_browser["page"].wait_for_selector.side_effect = Exception("Element not found")
# 使用我们的 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.click("#missing-button")
# Verify error result
assert result["success"] is False
assert "Element not found" in result["error"]
assert result["selector"] == "#missing-button"
@pytest.mark.asyncio
async def test_fill_success(self, settings, mock_playwright, mock_browser):
"""Test successful form filling."""
browser_automation = BrowserAutomation(settings)
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.fill("#input", "test text")
# Verify fill
mock_browser["page"].wait_for_selector.assert_called_once_with("#input", timeout=30000)
mock_browser["page"].fill.assert_called_once_with("#input", "test text")
# Verify result
assert result["success"] is True
assert result["selector"] == "#input"
assert result["text_length"] == 9
@pytest.mark.asyncio
async def test_fill_error(self, settings, mock_playwright, mock_browser):
"""Test fill error handling."""
browser_automation = BrowserAutomation(settings)
# Mock fill error
mock_browser["page"].fill.side_effect = Exception("Fill failed")
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.fill("#input", "test")
# Verify error result
assert result["success"] is False
assert "Fill failed" in result["error"]
assert result["selector"] == "#input"
@pytest.mark.asyncio
async def test_screenshot_success(self, settings, mock_playwright, mock_browser):
"""Test successful screenshot."""
browser_automation = BrowserAutomation(settings)
# Mock screenshot data
fake_image_data = b"fake_png_data"
mock_browser["page"].screenshot.return_value = fake_image_data
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.screenshot(full_page=True)
# Verify screenshot
mock_browser["page"].screenshot.assert_called_once_with(
full_page=True,
type="png"
)
# Verify result
assert result["success"] is True
assert result["image"] == base64.b64encode(fake_image_data).decode('utf-8')
assert result["mime_type"] == "image/png"
assert result["url"] == "https://example.com"
assert result["title"] == "Test Page"
@pytest.mark.asyncio
async def test_screenshot_error(self, settings, mock_playwright, mock_browser):
"""Test screenshot error handling."""
browser_automation = BrowserAutomation(settings)
# Mock screenshot error
mock_browser["page"].screenshot.side_effect = Exception("Screenshot failed")
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.screenshot()
# Verify error result
assert result["success"] is False
assert "Screenshot failed" in result["error"]
@pytest.mark.asyncio
async def test_extract_text_with_selector(self, settings, mock_playwright, mock_browser):
"""Test text extraction with selector."""
browser_automation = BrowserAutomation(settings)
# Mock element
mock_element = AsyncMock()
mock_element.text_content.return_value = "Extracted text"
mock_browser["page"].wait_for_selector.return_value = mock_element
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.extract_text("#content")
# Verify text extraction
mock_browser["page"].wait_for_selector.assert_called_once_with("#content")
mock_element.text_content.assert_called_once()
# Verify result
assert result["success"] is True
assert result["text"] == "Extracted text"
assert result["selector"] == "#content"
assert result["url"] == "https://example.com"
@pytest.mark.asyncio
async def test_extract_text_full_page(self, settings, mock_playwright, mock_browser):
"""Test full page text extraction."""
browser_automation = BrowserAutomation(settings)
# Mock page text
mock_browser["page"].text_content.return_value = "Full page text"
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.extract_text()
# Verify text extraction
mock_browser["page"].text_content.assert_called_once_with('body')
# Verify result
assert result["success"] is True
assert result["text"] == "Full page text"
assert result["selector"] is None
@pytest.mark.asyncio
async def test_wait_for_element_success(self, settings, mock_playwright, mock_browser):
"""Test successful element waiting."""
browser_automation = BrowserAutomation(settings)
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.wait_for_element("#element", timeout=5000)
# Verify wait
mock_browser["page"].wait_for_selector.assert_called_once_with(
"#element",
timeout=5000,
state="visible"
)
# Verify result
assert result["success"] is True
assert result["selector"] == "#element"
assert result["state"] == "visible"
@pytest.mark.asyncio
async def test_evaluate_script_success(self, settings, mock_playwright, mock_browser):
"""Test successful script evaluation."""
browser_automation = BrowserAutomation(settings)
# Mock script result
mock_browser["page"].evaluate.return_value = {"result": "success"}
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.evaluate_script("document.title")
# Verify script evaluation
mock_browser["page"].evaluate.assert_called_once_with("document.title")
# Verify result
assert result["success"] is True
assert result["result"] == {"result": "success"}
@pytest.mark.asyncio
async def test_get_page_info_success(self, settings, mock_playwright, mock_browser):
"""Test successful page info retrieval."""
browser_automation = BrowserAutomation(settings)
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.get_page_info()
# Verify page info calls
mock_browser["page"].title.assert_called_once()
# Verify result
assert result["success"] is True
assert result["url"] == "https://example.com"
assert result["title"] == "Test Page"
assert result["viewport"] == {"width": 1920, "height": 1080}
@pytest.mark.asyncio
async def test_page_not_initialized_error(self, settings):
"""Test error when page is not initialized."""
browser_automation = BrowserAutomation(settings)
browser_automation.browser = AsyncMock() # Browser set but page not
result = await browser_automation.click("#button")
assert result["success"] is False
assert "Page not initialized" in result["error"]
@pytest.mark.asyncio
async def test_close_cleanup(self, settings, mock_playwright, mock_browser):
"""Test proper cleanup on close."""
browser_automation = BrowserAutomation(settings)
# Set up browser state
browser_automation.playwright = mock_playwright
browser_automation.browser = mock_browser["browser"]
browser_automation.context = mock_browser["context"]
browser_automation.page = mock_browser["page"]
await browser_automation.close()
# Verify cleanup calls
mock_browser["page"].close.assert_called_once()
mock_browser["context"].close.assert_called_once()
mock_browser["browser"].close.assert_called_once()
mock_playwright.stop.assert_called_once()
# Verify state reset
assert browser_automation.page is None
assert browser_automation.context is None
assert browser_automation.browser is None
assert browser_automation.playwright is None
@pytest.mark.asyncio
async def test_close_with_errors(self, settings):
"""Test close with cleanup errors."""
browser_automation = BrowserAutomation(settings)
# Mock objects that raise errors
mock_page = AsyncMock()
mock_page.close.side_effect = Exception("Page close error")
mock_context = AsyncMock()
mock_context.close.side_effect = Exception("Context close error")
mock_browser = AsyncMock()
mock_browser.close.side_effect = Exception("Browser close error")
mock_playwright = AsyncMock()
mock_playwright.stop.side_effect = Exception("Playwright stop error")
browser_automation.page = mock_page
browser_automation.context = mock_context
browser_automation.browser = mock_browser
browser_automation.playwright = mock_playwright
# Should not raise exceptions
await browser_automation.close()
# State should still be reset
assert browser_automation.page is None
assert browser_automation.context is None
assert browser_automation.browser is None
assert browser_automation.playwright is None
class TestBrowserToolsEdgeCases:
"""Test edge cases and error scenarios."""
def setup_mocked_browser(self, browser_automation: BrowserAutomation, mock_playwright: PlaywrightMock, mock_browser: Dict[str, Any]) -> None:
"""Setup mocked browser components."""
async def mock_ensure_browser():
# 使用类型转换绕过类型检查
browser_automation.playwright = cast(Any, mock_playwright)
browser_automation.browser = mock_browser["browser"]
browser_automation.context = mock_browser["context"]
browser_automation.page = mock_browser["page"]
# 确保页面对象有我们需要的方法和属性
if not hasattr(mock_browser["page"], "url"):
# 使用 __dict__ 直接设置属性绕过类型检查
mock_browser["page"].__dict__["url"] = "https://example.com"
browser_automation._ensure_browser = mock_ensure_browser
@pytest.mark.asyncio
async def test_navigate_no_response(self, settings, mock_playwright, mock_browser):
"""Test navigation with no response."""
browser_automation = BrowserAutomation(settings)
# Mock no response
mock_browser["page"].goto.return_value = None
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.navigate("https://example.com")
# Should handle None response gracefully
assert result["success"] is True
assert result["status"] is None
@pytest.mark.asyncio
async def test_extract_text_no_element(self, settings, mock_playwright, mock_browser):
"""Test text extraction when element is not found."""
browser_automation = BrowserAutomation(settings)
# Mock no element found
mock_browser["page"].wait_for_selector.return_value = None
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.extract_text("#missing")
# Should handle missing element gracefully
assert result["success"] is True
assert result["text"] == ""
@pytest.mark.asyncio
async def test_extract_text_empty_content(self, settings, mock_playwright, mock_browser):
"""Test text extraction with empty content."""
browser_automation = BrowserAutomation(settings)
# Mock element with no text
mock_element = AsyncMock()
mock_element.text_content.return_value = None
mock_browser["page"].wait_for_selector.return_value = mock_element
# 使用 setup_mocked_browser 方法
self.setup_mocked_browser(browser_automation, mock_playwright, mock_browser)
result = await browser_automation.extract_text("#empty")
# Should handle None text content
assert result["success"] is True
assert result["text"] == ""