Files
hive/tools/tests/test_browser_tools_comprehensive.py
T
2026-05-01 14:55:20 -07:00

886 lines
37 KiB
Python

"""Comprehensive tests for browser tools with FastMCP fixtures.
Tests cover:
- Multiple subagents with multiple tab groups
- Complex script execution for LinkedIn, Twitter, YouTube
- Tab lifecycle management
- Navigation and interactions
- Error handling and edge cases
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastmcp import FastMCP
from gcu.browser.bridge import BeelineBridge
from gcu.browser.tools.advanced import register_advanced_tools
from gcu.browser.tools.inspection import register_inspection_tools
from gcu.browser.tools.interactions import register_interaction_tools
from gcu.browser.tools.lifecycle import register_lifecycle_tools
from gcu.browser.tools.navigation import register_navigation_tools
from gcu.browser.tools.tabs import register_tab_tools
# ─────────────────────────────────────────────────────────────────────────────
# Fixtures
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def mcp() -> FastMCP:
"""Create a fresh FastMCP instance for testing."""
return FastMCP("test-browser-comprehensive")
@pytest.fixture
def mock_bridge() -> MagicMock:
"""Create a mock BeelineBridge with common methods pre-configured."""
bridge = MagicMock(spec=BeelineBridge)
bridge.is_connected = True
bridge._cdp_attached = set()
# Context management
bridge.create_context = AsyncMock(return_value={"groupId": 1, "tabId": 100})
bridge.destroy_context = AsyncMock(return_value={"ok": True})
# Tab management
bridge.create_tab = AsyncMock(return_value={"tabId": 101})
bridge.close_tab = AsyncMock(return_value={"ok": True})
bridge.list_tabs = AsyncMock(return_value={"tabs": []})
bridge.activate_tab = AsyncMock(return_value={"ok": True})
# Navigation
bridge.navigate = AsyncMock(return_value={"ok": True, "url": "https://example.com"})
bridge.go_back = AsyncMock(return_value={"ok": True})
bridge.go_forward = AsyncMock(return_value={"ok": True})
bridge.reload = AsyncMock(return_value={"ok": True})
# Interactions
bridge.click = AsyncMock(return_value={"ok": True})
bridge.click_coordinate = AsyncMock(return_value={"ok": True})
bridge.type_text = AsyncMock(return_value={"ok": True})
bridge.press_key = AsyncMock(return_value={"ok": True})
bridge.hover = AsyncMock(return_value={"ok": True})
bridge.scroll = AsyncMock(return_value={"ok": True})
bridge.select_option = AsyncMock(return_value={"ok": True, "selected": ["option1"]})
bridge.drag = AsyncMock(return_value={"ok": True})
# Inspection
bridge.evaluate = AsyncMock(return_value={"result": {"value": True}})
bridge.snapshot = AsyncMock(return_value={"tree": "mock_accessibility_tree"})
bridge.screenshot = AsyncMock(return_value={"data": "base64imagedata"})
bridge.get_text = AsyncMock(return_value={"text": "sample text"})
bridge.get_attribute = AsyncMock(return_value={"value": "attribute_value"})
# Advanced
bridge.wait_for_selector = AsyncMock(return_value={"ok": True})
bridge.wait_for_text = AsyncMock(return_value={"ok": True})
bridge.resize = AsyncMock(return_value={"ok": True})
bridge.upload_file = AsyncMock(return_value={"ok": True})
bridge.handle_dialog = AsyncMock(return_value={"ok": True})
bridge.cdp_attach = AsyncMock(return_value={"ok": True})
bridge.cdp_detach = AsyncMock(return_value={"ok": True})
return bridge
# ─────────────────────────────────────────────────────────────────────────────
# Test Classes
# ─────────────────────────────────────────────────────────────────────────────
class TestMultipleSubagentsTabGroups:
"""Tests for multiple subagents creating and managing multiple tab groups."""
@pytest.mark.asyncio
async def test_multiple_agents_create_separate_tab_groups(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Multiple subagents should each create their own tab group."""
call_count = 0
async def mock_create_context(agent_id: str) -> dict:
nonlocal call_count
call_count += 1
return {"groupId": call_count, "tabId": 100 + call_count}
mock_bridge.create_context = AsyncMock(side_effect=mock_create_context)
from gcu.browser.tools.lifecycle import _ensure_context
results = await asyncio.gather(
_ensure_context(mock_bridge, "agent_1"),
_ensure_context(mock_bridge, "agent_2"),
_ensure_context(mock_bridge, "agent_3"),
)
# Each should have created a separate context
assert mock_bridge.create_context.call_count == 3
assert all(created for (_, _, created) in results)
@pytest.mark.asyncio
async def test_concurrent_tab_operations_different_groups(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Tab operations in different groups should not interfere."""
group1_tabs = [
{"id": 101, "url": "https://site1.com", "title": "Site 1"},
{"id": 102, "url": "https://site2.com", "title": "Site 2"},
]
group2_tabs = [
{"id": 201, "url": "https://site3.com", "title": "Site 3"},
{"id": 202, "url": "https://site4.com", "title": "Site 4"},
]
def mock_list_tabs(group_id: int) -> dict:
if group_id == 1:
return {"tabs": group1_tabs}
elif group_id == 2:
return {"tabs": group2_tabs}
return {"tabs": []}
mock_bridge.list_tabs = AsyncMock(side_effect=mock_list_tabs)
register_tab_tools(mcp)
browser_tabs = mcp._tool_manager._tools["browser_tabs"].fn
with patch("gcu.browser.tools.tabs.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.tabs._get_context",
side_effect=lambda p: {
"groupId": 1 if p == "agent_1" else 2,
"activeTabId": 101 if p == "agent_1" else 201,
},
):
# Concurrent tab listing from different agents
results = await asyncio.gather(
browser_tabs(profile="agent_1"),
browser_tabs(profile="agent_2"),
)
# Each should see only their own tabs
assert len(results[0].get("tabs", [])) == 2
assert len(results[1].get("tabs", [])) == 2
assert results[0]["tabs"][0]["id"] == 101
assert results[1]["tabs"][0]["id"] == 201
@pytest.mark.asyncio
async def test_tab_group_isolation(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Closing a tab in one group should not affect other groups."""
closed_tabs = []
async def mock_close_tab(tab_id: int) -> dict:
closed_tabs.append(tab_id)
return {"ok": True}
mock_bridge.close_tab = AsyncMock(side_effect=mock_close_tab)
register_tab_tools(mcp)
browser_close = mcp._tool_manager._tools["browser_close"].fn
with patch("gcu.browser.tools.tabs.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.tabs._get_context",
return_value={"groupId": 1, "activeTabId": 101},
):
result = await browser_close(tab_id=101, profile="agent_1")
assert result.get("ok") is True
assert 101 in closed_tabs
class TestComplexScriptExecution:
"""Tests for complex JavaScript execution patterns on real-world sites."""
@pytest.mark.asyncio
async def test_linkedin_scroll_infinite_feed(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test LinkedIn-style infinite feed scrolling with lazy loading."""
scroll_calls = []
async def mock_scroll(tab_id: int, direction: str, amount: int = 500, selector: str | None = None) -> dict:
scroll_calls.append((tab_id, direction, amount))
return {"ok": True}
mock_bridge.scroll = AsyncMock(side_effect=mock_scroll)
register_interaction_tools(mcp)
browser_scroll = mcp._tool_manager._tools["browser_scroll"].fn
with patch("gcu.browser.tools.interactions.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.interactions._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
# Simulate infinite scroll - multiple scroll operations
for _ in range(3):
await browser_scroll(direction="down", amount=500)
assert len(scroll_calls) == 3
@pytest.mark.asyncio
async def test_linkedin_profile_data_extraction(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test extracting LinkedIn profile data using complex selectors."""
profile_data = {
"name": "John Doe",
"title": "Software Engineer at Tech Corp",
}
mock_bridge.evaluate = AsyncMock(return_value={"result": {"value": profile_data}})
register_advanced_tools(mcp)
browser_evaluate = mcp._tool_manager._tools["browser_evaluate"].fn
with patch("gcu.browser.tools.advanced.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.advanced._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
# Extract profile data via JavaScript
extraction_script = """
const name = document.querySelector('.text-heading-xlarge')?.innerText;
const title = document.querySelector('.text-body-medium')?.innerText;
return { name, title };
"""
result = await browser_evaluate(script=extraction_script)
# browser_evaluate returns the raw result from bridge.evaluate
assert "result" in result
assert result["result"]["value"] == profile_data
@pytest.mark.asyncio
async def test_twitter_x_infinite_timeline(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test Twitter/X infinite timeline scrolling with tweet loading."""
tweets_loaded = ["tweet_0", "tweet_1", "tweet_2", "tweet_3", "tweet_4"]
mock_bridge.evaluate = AsyncMock(return_value={"result": {"value": tweets_loaded}})
mock_bridge.scroll = AsyncMock(return_value={"ok": True})
register_interaction_tools(mcp)
register_advanced_tools(mcp)
browser_scroll = mcp._tool_manager._tools["browser_scroll"].fn
browser_evaluate = mcp._tool_manager._tools["browser_evaluate"].fn
with patch("gcu.browser.tools.interactions.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.interactions._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
# Simulate Twitter timeline scroll
await browser_scroll(direction="down", amount=800)
with patch("gcu.browser.tools.advanced.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.advanced._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
extract_script = """
return Array.from(document.querySelectorAll('article[data-testid="tweet"]'))
.slice(0, 5)
.map(t => t.innerText);
"""
result = await browser_evaluate(script=extract_script)
# browser_evaluate returns raw result from bridge
assert "result" in result
assert result["result"]["value"] == tweets_loaded
@pytest.mark.asyncio
async def test_youtube_video_player_interaction(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test YouTube video player controls and state management."""
player_state = {"playing": False, "currentTime": 0, "duration": 300}
mock_bridge.evaluate = AsyncMock(return_value={"result": {"value": player_state}})
mock_bridge.click = AsyncMock(return_value={"ok": True})
register_advanced_tools(mcp)
register_interaction_tools(mcp)
browser_evaluate = mcp._tool_manager._tools["browser_evaluate"].fn
with patch("gcu.browser.tools.advanced.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.advanced._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
# Interact with YouTube player
play_script = """
document.querySelector('.ytp-play-button')?.click();
return true;
"""
result = await browser_evaluate(script=play_script)
# browser_evaluate returns raw result from bridge
assert "result" in result
@pytest.mark.asyncio
async def test_youtube_comments_expansion(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test YouTube comments section expansion and loading."""
comments = ["comment_1", "comment_2", "comment_3"]
mock_bridge.evaluate = AsyncMock(return_value={"result": {"value": comments}})
mock_bridge.scroll = AsyncMock(return_value={"ok": True})
mock_bridge.click = AsyncMock(return_value={"ok": True})
register_advanced_tools(mcp)
browser_evaluate = mcp._tool_manager._tools["browser_evaluate"].fn
with patch("gcu.browser.tools.advanced.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.advanced._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
# Scroll to comments and expand
expand_script = """
const commentsSection = document.querySelector('ytd-comments#comments');
if (commentsSection) {
commentsSection.scrollIntoView();
return true;
}
return false;
"""
result = await browser_evaluate(script=expand_script)
# browser_evaluate returns raw result from bridge
assert "result" in result
@pytest.mark.asyncio
async def test_complex_form_filling_linkedin(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test complex form filling on LinkedIn with dynamic fields."""
filled_fields = {}
async def mock_type_text(tab_id: int, selector: str, text: str, **kwargs) -> dict:
filled_fields[selector] = text
return {"ok": True}
async def mock_select_option(tab_id: int, selector: str, values: list, **kwargs) -> dict:
filled_fields[selector] = values
return {"ok": True, "selected": values}
mock_bridge.type_text = AsyncMock(side_effect=mock_type_text)
mock_bridge.select_option = AsyncMock(side_effect=mock_select_option)
register_interaction_tools(mcp)
browser_type = mcp._tool_manager._tools["browser_type"].fn
browser_select = mcp._tool_manager._tools["browser_select"].fn
with patch("gcu.browser.tools.interactions.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.interactions._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
# Fill out a LinkedIn job application form
await browser_type(selector="#first-name", text="John")
await browser_type(selector="#last-name", text="Doe")
await browser_type(selector="#email", text="john.doe@example.com")
await browser_select(selector="#experience-level", values=["5-10 years"])
assert filled_fields.get("#first-name") == "John"
assert filled_fields.get("#last-name") == "Doe"
assert filled_fields.get("#email") == "john.doe@example.com"
class TestTabLifecycle:
"""Tests for tab lifecycle management."""
@pytest.mark.asyncio
async def test_create_and_close_tab(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test creating and closing a tab."""
mock_bridge.create_tab = AsyncMock(return_value={"tabId": 123})
mock_bridge.close_tab = AsyncMock(return_value={"ok": True})
register_tab_tools(mcp)
browser_open = mcp._tool_manager._tools["browser_open"].fn
browser_close = mcp._tool_manager._tools["browser_close"].fn
with patch("gcu.browser.tools.tabs.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.tabs._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
open_result = await browser_open(url="https://example.com")
assert open_result.get("ok") is True
close_result = await browser_close(tab_id=123)
assert close_result.get("ok") is True
@pytest.mark.asyncio
async def test_tab_activate_switching(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test switching the active tab."""
mock_bridge.activate_tab = AsyncMock(return_value={"ok": True})
register_tab_tools(mcp)
browser_activate_tab = mcp._tool_manager._tools["browser_activate_tab"].fn
with patch("gcu.browser.tools.tabs.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.tabs._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_activate_tab(tab_id=200)
assert result.get("ok") is True
mock_bridge.activate_tab.assert_awaited_once_with(200)
class TestNavigation:
"""Tests for navigation tools."""
@pytest.mark.asyncio
async def test_navigate_with_wait_until(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test navigation with different wait_until options."""
mock_bridge.navigate = AsyncMock(return_value={"ok": True, "url": "https://example.com"})
register_navigation_tools(mcp)
browser_navigate = mcp._tool_manager._tools["browser_navigate"].fn
with patch("gcu.browser.tools.navigation.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.navigation._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_navigate(url="https://example.com", wait_until="networkidle")
assert result.get("ok") is True
# The bridge.navigate is called with wait_until as keyword argument
mock_bridge.navigate.assert_awaited_once_with(100, "https://example.com", wait_until="networkidle")
@pytest.mark.asyncio
async def test_navigation_history(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test back/forward navigation."""
mock_bridge.go_back = AsyncMock(return_value={"ok": True})
mock_bridge.go_forward = AsyncMock(return_value={"ok": True})
register_navigation_tools(mcp)
browser_go_back = mcp._tool_manager._tools["browser_go_back"].fn
browser_go_forward = mcp._tool_manager._tools["browser_go_forward"].fn
with patch("gcu.browser.tools.navigation.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.navigation._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
back_result = await browser_go_back()
forward_result = await browser_go_forward()
assert back_result.get("ok") is True
assert forward_result.get("ok") is True
class TestInteractions:
"""Tests for interaction tools."""
@pytest.mark.asyncio
async def test_click_with_different_buttons(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test clicking with left, right, and middle buttons."""
click_calls = []
async def track_click(tab_id: int, selector: str, button: str = "left", **kwargs) -> dict:
click_calls.append((tab_id, selector, button))
return {"ok": True}
mock_bridge.click = AsyncMock(side_effect=track_click)
register_interaction_tools(mcp)
browser_click = mcp._tool_manager._tools["browser_click"].fn
with patch("gcu.browser.tools.interactions.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.interactions._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
await browser_click(selector="button", button="left")
await browser_click(selector="button", button="right")
await browser_click(selector="button", button="middle")
assert len(click_calls) == 3
assert [c[2] for c in click_calls] == ["left", "right", "middle"]
@pytest.mark.asyncio
async def test_type_with_special_characters(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test typing text with special characters and unicode."""
typed_texts = []
async def track_type(tab_id: int, selector: str, text: str, **kwargs) -> dict:
typed_texts.append(text)
return {"ok": True}
mock_bridge.type_text = AsyncMock(side_effect=track_type)
register_interaction_tools(mcp)
browser_type = mcp._tool_manager._tools["browser_type"].fn
with patch("gcu.browser.tools.interactions.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.interactions._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
# Test various special characters
special_texts = [
"Hello, World!", # Basic punctuation
"O'Reilly & Associates", # Quotes and ampersands
"Price: $100 (20% off)", # Currency and parentheses
"Email: user@example.com", # Email format
"日本語テスト", # Japanese characters
"Émojis: 🎉🚀💻", # Emojis
]
for text in special_texts:
result = await browser_type(selector="input", text=text)
assert result.get("ok") is True
assert typed_texts == special_texts
@pytest.mark.asyncio
async def test_drag_and_drop(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test drag and drop operation."""
# browser_drag uses _cdp directly for DOM queries and mouse events
mock_bridge._cdp = AsyncMock(
side_effect=lambda tab_id, method, params=None: {
"DOM.getDocument": {"root": {"nodeId": 1}},
"DOM.querySelector": {"nodeId": 2},
"DOM.getBoxModel": {"content": [0, 0, 100, 0, 100, 50, 0, 50]},
"Input.dispatchMouseEvent": {},
}.get(method, {})
)
register_interaction_tools(mcp)
browser_drag = mcp._tool_manager._tools["browser_drag"].fn
with patch("gcu.browser.tools.interactions.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.interactions._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_drag(
start_selector="#draggable",
end_selector="#dropzone",
)
assert result.get("ok") is True
class TestInspection:
"""Tests for inspection tools."""
@pytest.mark.asyncio
async def test_snapshot_accessibility_tree(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test getting accessibility tree snapshot."""
mock_snapshot = """
[1] document "Page Title"
[2] button "Submit"
[3] textbox "Search"
"""
mock_bridge.snapshot = AsyncMock(return_value={"tree": mock_snapshot})
register_inspection_tools(mcp)
browser_snapshot = mcp._tool_manager._tools["browser_snapshot"].fn
with patch("gcu.browser.tools.inspection.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.inspection._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_snapshot()
# browser_snapshot returns raw result from bridge
assert "tree" in result
@pytest.mark.asyncio
async def test_screenshot_full_page(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test taking full page screenshot."""
mock_bridge.screenshot = AsyncMock(
return_value={
"ok": True,
"data": "base64encodedimagedata",
"width": 1920,
"height": 5000,
}
)
register_inspection_tools(mcp)
browser_screenshot = mcp._tool_manager._tools["browser_screenshot"].fn
with patch("gcu.browser.tools.inspection.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.inspection._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_screenshot(full_page=True)
# browser_screenshot returns list of content blocks
assert isinstance(result, list)
mock_bridge.screenshot.assert_awaited_once_with(100, full_page=True, selector=None)
class TestAdvancedTools:
"""Tests for advanced tools."""
@pytest.mark.asyncio
async def test_wait_for_selector_timeout(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test wait_for_selector timeout behavior."""
mock_bridge.wait_for_selector = AsyncMock(side_effect=TimeoutError("Element not found within timeout"))
register_advanced_tools(mcp)
browser_wait = mcp._tool_manager._tools["browser_wait"].fn
with patch("gcu.browser.tools.advanced.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.advanced._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_wait(selector=".nonexistent", timeout_ms=1000)
# Should return error result, not raise
assert result.get("ok") is False
assert "error" in result or "timed out" in str(result).lower()
@pytest.mark.asyncio
async def test_evaluate_with_return_value(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test JavaScript evaluation with return value."""
mock_bridge.evaluate = AsyncMock(return_value={"result": {"value": {"status": "success", "count": 42}}})
register_advanced_tools(mcp)
browser_evaluate = mcp._tool_manager._tools["browser_evaluate"].fn
with patch("gcu.browser.tools.advanced.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.advanced._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_evaluate(script="return { status: 'success', count: 42 };")
# browser_evaluate returns raw result from bridge
assert "result" in result
assert result["result"]["value"]["status"] == "success"
@pytest.mark.asyncio
async def test_file_upload(self, mcp: FastMCP, mock_bridge: MagicMock, tmp_path):
"""Test file upload functionality."""
# Create real files — browser_upload validates they exist on disk
file1 = tmp_path / "file1.pdf"
file2 = tmp_path / "file2.pdf"
file1.write_bytes(b"fake pdf 1")
file2.write_bytes(b"fake pdf 2")
# Mock the CDP calls used by browser_upload
mock_bridge.cdp_attach = AsyncMock(return_value={"ok": True})
async def mock_cdp(tab_id, method, params=None):
if method == "DOM.getDocument":
return {"root": {"nodeId": 1}}
if method == "DOM.querySelector":
return {"nodeId": 42}
if method == "DOM.setFileInputFiles":
return {"ok": True}
return {"ok": True}
mock_bridge._cdp = AsyncMock(side_effect=mock_cdp)
register_advanced_tools(mcp)
browser_upload = mcp._tool_manager._tools["browser_upload"].fn
with patch("gcu.browser.tools.advanced.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.advanced._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_upload(
selector="input[type='file']",
file_paths=[str(file1), str(file2)],
)
assert result.get("ok") is True
assert result.get("count") == 2
class TestErrorHandling:
"""Tests for error handling scenarios."""
@pytest.mark.asyncio
async def test_bridge_not_connected(self, mcp: FastMCP):
"""Test behavior when bridge is not connected."""
mock_bridge = MagicMock(spec=BeelineBridge)
mock_bridge.is_connected = False
register_tab_tools(mcp)
browser_open = mcp._tool_manager._tools["browser_open"].fn
with patch("gcu.browser.tools.tabs.get_bridge", return_value=mock_bridge):
result = await browser_open(url="https://example.com", profile="test")
assert result.get("ok") is False
assert "not connected" in result.get("error", "").lower()
@pytest.mark.asyncio
async def test_browser_not_started(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test behavior when browser is not started."""
register_tab_tools(mcp)
browser_tabs = mcp._tool_manager._tools["browser_tabs"].fn
with patch("gcu.browser.tools.tabs.get_bridge", return_value=mock_bridge):
with patch("gcu.browser.tools.tabs._get_context", return_value=None):
result = await browser_tabs(profile="nonexistent")
assert result.get("ok") is False
assert "not started" in result.get("error", "").lower()
@pytest.mark.asyncio
async def test_cdp_command_failure(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test handling of CDP command failures."""
mock_bridge.click = AsyncMock(side_effect=RuntimeError("CDP error: Element not found"))
register_interaction_tools(mcp)
browser_click = mcp._tool_manager._tools["browser_click"].fn
with patch("gcu.browser.tools.interactions.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.interactions._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_click(selector=".nonexistent")
assert result.get("ok") is False
assert "error" in result
class TestIFWrapping:
"""Tests for JavaScript IIFE wrapping to handle return statements."""
@pytest.mark.asyncio
async def test_evaluate_passes_script_through_to_bridge(self, mcp: FastMCP, mock_bridge: MagicMock):
"""browser_evaluate should pass the script through to bridge.evaluate unchanged.
IIFE wrapping happens inside bridge.evaluate (see bridge.py), not in
the tool layer. The tool's job is just to forward the script.
"""
call_args = []
async def mock_evaluate_capture(tab_id: int, script: str) -> dict:
call_args.append(script)
return {"result": {"value": 42}}
mock_bridge.evaluate = AsyncMock(side_effect=mock_evaluate_capture)
register_advanced_tools(mcp)
browser_evaluate = mcp._tool_manager._tools["browser_evaluate"].fn
with patch("gcu.browser.tools.advanced.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.advanced._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
result = await browser_evaluate(script="return 42;")
# Tool may issue a toast call then the actual script call
assert len(call_args) >= 1
assert any("return 42;" in arg for arg in call_args)
# Tool returns bridge's raw result
assert result == {"result": {"value": 42}}
@pytest.mark.asyncio
async def test_evaluate_complex_script(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test complex multi-line script execution."""
mock_bridge.evaluate = AsyncMock(return_value={"result": {"value": {"total": 100, "filtered": 50}}})
register_advanced_tools(mcp)
browser_evaluate = mcp._tool_manager._tools["browser_evaluate"].fn
with patch("gcu.browser.tools.advanced.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.advanced._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
complex_script = """
const items = document.querySelectorAll('.item');
const filtered = Array.from(items).filter(i => i.classList.contains('active'));
return {
total: items.length,
filtered: filtered.length
};
"""
result = await browser_evaluate(script=complex_script)
# browser_evaluate returns bridge.evaluate's raw result
assert "result" in result
assert result["result"]["value"] == {"total": 100, "filtered": 50}
class TestConcurrentOperations:
"""Tests for concurrent browser operations."""
@pytest.mark.asyncio
async def test_concurrent_clicks_different_tabs(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test clicking on multiple tabs concurrently."""
click_order = []
async def mock_click(tab_id: int, selector: str, **kwargs) -> dict:
click_order.append(tab_id)
await asyncio.sleep(0.01) # Simulate async operation
return {"ok": True}
mock_bridge.click = AsyncMock(side_effect=mock_click)
register_interaction_tools(mcp)
browser_click = mcp._tool_manager._tools["browser_click"].fn
with patch("gcu.browser.tools.interactions.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.interactions._get_context",
side_effect=lambda p: {
"groupId": 1 if p == "agent_1" else 2 if p == "agent_2" else 3,
"activeTabId": 101 if p == "agent_1" else 201 if p == "agent_2" else 301,
},
):
# Concurrent clicks from different agents
await asyncio.gather(
browser_click(selector="button", profile="agent_1"),
browser_click(selector="button", profile="agent_2"),
browser_click(selector="button", profile="agent_3"),
)
# All clicks should have been executed
assert len(click_order) == 3
assert set(click_order) == {101, 201, 301}
@pytest.mark.asyncio
async def test_mixed_operations_same_tab(self, mcp: FastMCP, mock_bridge: MagicMock):
"""Test mixed operations (click, type, scroll) on same tab."""
operations = []
async def track_click(tab_id: int, selector: str, **kwargs) -> dict:
operations.append("click")
return {"ok": True}
async def track_type(tab_id: int, selector: str, text: str, **kwargs) -> dict:
operations.append("type")
return {"ok": True}
async def track_scroll(tab_id: int, direction: str, **kwargs) -> dict:
operations.append("scroll")
return {"ok": True}
mock_bridge.click = AsyncMock(side_effect=track_click)
mock_bridge.type_text = AsyncMock(side_effect=track_type)
mock_bridge.scroll = AsyncMock(side_effect=track_scroll)
register_interaction_tools(mcp)
browser_click = mcp._tool_manager._tools["browser_click"].fn
browser_type = mcp._tool_manager._tools["browser_type"].fn
browser_scroll = mcp._tool_manager._tools["browser_scroll"].fn
with patch("gcu.browser.tools.interactions.get_bridge", return_value=mock_bridge):
with patch(
"gcu.browser.tools.interactions._get_context",
return_value={"groupId": 1, "activeTabId": 100},
):
# Mix of operations
await browser_click(selector="button")
await browser_type(selector="input", text="hello")
await browser_scroll(direction="down")
assert "click" in operations
assert "type" in operations
assert "scroll" in operations