mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
Enhanced agent tool architecture: - 8 CLI agents: claude, codex, gemini, grok, qwen, vibe, code, dev - Auto-detects agent environment for seamless auth - Shares hanzo-mcp config with spawned agents - Lightweight - no heavy litellm dependency Essential tools reorganization: - Added agent, think, critic to essential tools - Removed llm/consensus from essential (heavy deps, opt-in) - HEAVY_TOOLS constant for tracking opt-in tools Config sharing: - HANZO_AGENT_PARENT=true passed to child agents - MCP config env vars propagated - API keys shared when appropriate
1689 lines
72 KiB
Python
1689 lines
72 KiB
Python
"""High-performance async browser automation tool using Playwright.
|
|
|
|
Design goals:
|
|
- Async-first: All operations are non-blocking
|
|
- Shared browser instance: Reuse browser across calls (low latency)
|
|
- Connection pooling: Multiple pages/contexts for parallel work
|
|
- Cross-MCP sharing: Connect to existing browser via CDP endpoint
|
|
- Full Playwright API: Complete surface area coverage
|
|
- Touch support: Mobile device emulation with touch events
|
|
- Network control: Intercept, mock, and monitor requests
|
|
|
|
PARALLEL AGENTS ARCHITECTURE:
|
|
- BrowserPool is a singleton - one Chrome process per MCP server
|
|
- Each agent can use `new_context` to get an isolated browser context
|
|
- Contexts have separate: cookies, localStorage, sessionStorage, cache
|
|
- Tabs within same context share state (use for same-session workflows)
|
|
- For true multi-process sharing, launch Chrome with CDP and connect:
|
|
BROWSER_CDP_ENDPOINT=http://localhost:9222 hanzo-mcp
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import json
|
|
import base64
|
|
import asyncio
|
|
import logging
|
|
from typing import Any, Union, Literal, ClassVar, Optional, Annotated
|
|
from pathlib import Path
|
|
from dataclasses import field, dataclass
|
|
|
|
from pydantic import Field
|
|
from mcp.server import FastMCP
|
|
|
|
from hanzo_tools.core import BaseTool
|
|
|
|
# Playwright import with graceful fallback
|
|
try:
|
|
from playwright.async_api import (
|
|
Page,
|
|
Route,
|
|
Dialog,
|
|
Browser,
|
|
Locator,
|
|
Request,
|
|
Download,
|
|
Response,
|
|
Playwright,
|
|
BrowserContext,
|
|
ConsoleMessage,
|
|
async_playwright,
|
|
)
|
|
|
|
PLAYWRIGHT_AVAILABLE = True
|
|
except ImportError:
|
|
PLAYWRIGHT_AVAILABLE = False
|
|
Browser = Page = BrowserContext = Playwright = None
|
|
Route = Request = Response = Dialog = ConsoleMessage = Download = Locator = None
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Device presets - user-friendly aliases + specific devices
|
|
DEVICES = {
|
|
# User-friendly aliases
|
|
"mobile": {
|
|
"viewport": {"width": 390, "height": 844},
|
|
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
"device_scale_factor": 3,
|
|
"is_mobile": True,
|
|
"has_touch": True,
|
|
},
|
|
"tablet": {
|
|
"viewport": {"width": 1024, "height": 1366},
|
|
"user_agent": "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
"device_scale_factor": 2,
|
|
"is_mobile": True,
|
|
"has_touch": True,
|
|
},
|
|
"laptop": {
|
|
"viewport": {"width": 1440, "height": 900},
|
|
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
"device_scale_factor": 2,
|
|
"is_mobile": False,
|
|
"has_touch": False,
|
|
},
|
|
"desktop": { # Alias for laptop
|
|
"viewport": {"width": 1920, "height": 1080},
|
|
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
"device_scale_factor": 1,
|
|
"is_mobile": False,
|
|
"has_touch": False,
|
|
},
|
|
# Specific devices
|
|
"iphone_14": {
|
|
"viewport": {"width": 390, "height": 844},
|
|
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1",
|
|
"device_scale_factor": 3,
|
|
"is_mobile": True,
|
|
"has_touch": True,
|
|
},
|
|
"iphone_15_pro": {
|
|
"viewport": {"width": 393, "height": 852},
|
|
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
"device_scale_factor": 3,
|
|
"is_mobile": True,
|
|
"has_touch": True,
|
|
},
|
|
"pixel_7": {
|
|
"viewport": {"width": 412, "height": 915},
|
|
"user_agent": "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
|
"device_scale_factor": 2.625,
|
|
"is_mobile": True,
|
|
"has_touch": True,
|
|
},
|
|
"ipad_pro": {
|
|
"viewport": {"width": 1024, "height": 1366},
|
|
"user_agent": "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
"device_scale_factor": 2,
|
|
"is_mobile": True,
|
|
"has_touch": True,
|
|
},
|
|
"galaxy_s23": {
|
|
"viewport": {"width": 360, "height": 780},
|
|
"user_agent": "Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
|
"device_scale_factor": 3,
|
|
"is_mobile": True,
|
|
"has_touch": True,
|
|
},
|
|
}
|
|
|
|
|
|
Action = Annotated[
|
|
Literal[
|
|
# === Core Page Navigation & Lifecycle ===
|
|
"navigate", # goto(url)
|
|
"set_content", # setContent(html)
|
|
"content", # content() - get full HTML
|
|
"url", # url() - get current URL
|
|
"title", # title() - get page title
|
|
"reload", # reload()
|
|
"go_back", # goBack()
|
|
"go_forward", # goForward()
|
|
"close", # close page/browser
|
|
# === Input - Click/Type ===
|
|
"click", # click(selector)
|
|
"dblclick", # dblclick(selector)
|
|
"type", # type(selector, text) - character by character
|
|
"fill", # fill(selector, text) - instant, clears first
|
|
"clear", # clear input
|
|
"press", # press key combo (Ctrl+A, Enter, etc.)
|
|
# === Input - Forms ===
|
|
"select_option", # select dropdown option
|
|
"check", # check checkbox/radio
|
|
"uncheck", # uncheck checkbox
|
|
"upload", # set_input_files
|
|
# === Mouse ===
|
|
"hover", # hover(selector)
|
|
"drag", # drag_and_drop(source, target)
|
|
"mouse_move", # mouse.move(x, y)
|
|
"mouse_down", # mouse.down()
|
|
"mouse_up", # mouse.up()
|
|
"mouse_wheel", # mouse.wheel(dx, dy)
|
|
"scroll", # scroll element into view or scroll by delta
|
|
# === Touch (Mobile) ===
|
|
"tap", # tap(selector) - touch tap
|
|
"swipe", # swipe gesture
|
|
"pinch", # pinch zoom
|
|
# === Locator Creation ===
|
|
"locator", # Create locator (CSS, text, role, xpath)
|
|
"frame_locator", # frameLocator(selector)
|
|
# === Built-in Locators (get_by_*) ===
|
|
"get_by_role", # getByRole(role, {name})
|
|
"get_by_text", # getByText(text)
|
|
"get_by_label", # getByLabel(text)
|
|
"get_by_placeholder", # getByPlaceholder(text)
|
|
"get_by_test_id", # getByTestId(id)
|
|
"get_by_alt_text", # getByAltText(text)
|
|
"get_by_title", # getByTitle(text)
|
|
# === Locator Composition ===
|
|
"first", # locator.first
|
|
"last", # locator.last
|
|
"nth", # locator.nth(index)
|
|
"filter", # locator.filter({has, hasText, hasNotText})
|
|
"all", # locator.all() - get all matching
|
|
"count", # locator.count()
|
|
# === Content Extraction ===
|
|
"get_text", # textContent()
|
|
"get_inner_text", # innerText()
|
|
"get_attribute", # getAttribute(name)
|
|
"get_value", # inputValue()
|
|
"get_html", # innerHTML() or content()
|
|
"get_bounding_box", # boundingBox()
|
|
# === State Checks ===
|
|
"is_visible", # isVisible()
|
|
"is_enabled", # isEnabled()
|
|
"is_checked", # isChecked()
|
|
"is_hidden", # isHidden()
|
|
"is_editable", # isEditable()
|
|
# === Assertions (expect) ===
|
|
"expect_visible", # expect(loc).toBeVisible()
|
|
"expect_hidden", # expect(loc).toBeHidden()
|
|
"expect_enabled", # expect(loc).toBeEnabled()
|
|
"expect_text", # expect(loc).toHaveText()
|
|
"expect_value", # expect(loc).toHaveValue()
|
|
"expect_checked", # expect(loc).toBeChecked()
|
|
"expect_url", # expect(page).toHaveURL()
|
|
"expect_title", # expect(page).toHaveTitle()
|
|
"expect_count", # expect(loc).toHaveCount()
|
|
"expect_attribute", # expect(loc).toHaveAttribute()
|
|
# === Page Actions ===
|
|
"screenshot", # screenshot()
|
|
"pdf", # pdf()
|
|
"snapshot", # accessibility.snapshot()
|
|
"evaluate", # evaluate(js)
|
|
"focus", # focus(selector)
|
|
"blur", # blur()
|
|
# === Wait Primitives ===
|
|
"wait", # waitForSelector or sleep
|
|
"wait_for_load", # waitForLoadState(networkidle, etc)
|
|
"wait_for_url", # waitForURL(pattern)
|
|
"wait_for_event", # waitForEvent(event) - request, response, download, filechooser, popup
|
|
"wait_for_request", # waitForRequest(pattern)
|
|
"wait_for_response", # waitForResponse(pattern)
|
|
"wait_for_function", # waitForFunction(js)
|
|
# === Viewport & Device ===
|
|
"viewport", # setViewportSize
|
|
"emulate", # emulate device (mobile, tablet, laptop)
|
|
"geolocation", # setGeolocation
|
|
"permissions", # grantPermissions
|
|
# === Network Interception ===
|
|
"route", # route(pattern, handler) - mock/block
|
|
"unroute", # unroute(pattern)
|
|
# === Storage & Cookies ===
|
|
"cookies", # cookies() or addCookies()
|
|
"clear_cookies", # clearCookies()
|
|
"storage", # localStorage/sessionStorage
|
|
"storage_state", # storageState() - save/load auth
|
|
# === Events & Handlers ===
|
|
"on", # page.on(event, handler)
|
|
"off", # removeListener
|
|
# === Dialogs ===
|
|
"dialog", # handle pending dialog
|
|
# === Frames ===
|
|
"frame", # switch to frame
|
|
"main_frame", # back to main frame
|
|
# === File Chooser & Downloads ===
|
|
"file_chooser", # waitForEvent('filechooser')
|
|
"download", # waitForEvent('download')
|
|
# === Console & Errors ===
|
|
"console", # get console messages
|
|
"errors", # get page errors
|
|
# === Browser/Context Management ===
|
|
"new_page", # context.newPage()
|
|
"new_context", # browser.newContext() - isolated session
|
|
"new_tab", # alias for new_page
|
|
"close_tab", # close current page
|
|
"tabs", # list/switch tabs
|
|
"connect", # connect via CDP
|
|
"set_headless", # toggle headless/headed
|
|
"status", # get browser status
|
|
# === Debug/Tracing ===
|
|
"trace_start", # tracing.start()
|
|
"trace_stop", # tracing.stop()
|
|
"highlight", # highlight element for debugging
|
|
],
|
|
Field(description="Browser action to perform"),
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class BrowserState:
|
|
"""Track browser state for debugging and monitoring."""
|
|
|
|
console_messages: list[dict] = field(default_factory=list)
|
|
page_errors: list[str] = field(default_factory=list)
|
|
routes: dict[str, dict] = field(default_factory=dict)
|
|
event_handlers: dict[str, list] = field(default_factory=dict)
|
|
tracing: bool = False
|
|
pending_dialog: Any = None
|
|
pending_download: Any = None
|
|
pending_file_chooser: Any = None
|
|
|
|
|
|
class BrowserPool:
|
|
"""Shared browser instance pool for high-performance automation.
|
|
|
|
ARCHITECTURE FOR PARALLEL AGENTS:
|
|
- Singleton per MCP process - one Chrome, many contexts
|
|
- new_context() creates isolated sessions (separate cookies/storage)
|
|
- Tabs share context state, contexts are isolated
|
|
- For multi-process sharing, use CDP endpoint
|
|
"""
|
|
|
|
_instance: ClassVar[Optional["BrowserPool"]] = None
|
|
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
|
|
|
|
def __init__(self):
|
|
self._playwright: Optional[Playwright] = None
|
|
self._browser: Optional[Browser] = None
|
|
self._context: Optional[BrowserContext] = None
|
|
self._page: Optional[Page] = None
|
|
self._pages: list[Page] = []
|
|
self._contexts: list[BrowserContext] = []
|
|
self._headless: bool = True
|
|
self._cdp_endpoint: Optional[str] = None
|
|
self._initialized: bool = False
|
|
self._state: BrowserState = BrowserState()
|
|
self._device: Optional[str] = None
|
|
|
|
@classmethod
|
|
async def get_instance(cls) -> "BrowserPool":
|
|
"""Get or create the singleton browser pool."""
|
|
async with cls._lock:
|
|
if cls._instance is None:
|
|
cls._instance = BrowserPool()
|
|
return cls._instance
|
|
|
|
@classmethod
|
|
async def shutdown(cls) -> None:
|
|
"""Shutdown the browser pool."""
|
|
async with cls._lock:
|
|
if cls._instance is not None:
|
|
await cls._instance.close()
|
|
cls._instance = None
|
|
|
|
def _setup_page_listeners(self, page: Page) -> None:
|
|
"""Set up event listeners for a page."""
|
|
# Console messages
|
|
page.on(
|
|
"console",
|
|
lambda msg: self._state.console_messages.append(
|
|
{
|
|
"type": msg.type,
|
|
"text": msg.text,
|
|
"location": getattr(msg, "location", None),
|
|
}
|
|
),
|
|
)
|
|
|
|
# Page errors
|
|
page.on("pageerror", lambda err: self._state.page_errors.append(str(err)))
|
|
|
|
# Dialogs
|
|
async def handle_dialog(dialog: Dialog):
|
|
self._state.pending_dialog = dialog
|
|
|
|
page.on("dialog", handle_dialog)
|
|
|
|
# Downloads
|
|
def handle_download(download: Download):
|
|
self._state.pending_download = download
|
|
|
|
page.on("download", handle_download)
|
|
|
|
# File chooser
|
|
def handle_filechooser(file_chooser):
|
|
self._state.pending_file_chooser = file_chooser
|
|
|
|
page.on("filechooser", handle_filechooser)
|
|
|
|
async def ensure_browser(
|
|
self,
|
|
headless: bool = True,
|
|
cdp_endpoint: Optional[str] = None,
|
|
device: Optional[str] = None,
|
|
) -> Page:
|
|
"""Ensure browser is running, return current page."""
|
|
if not PLAYWRIGHT_AVAILABLE:
|
|
raise RuntimeError("Playwright not installed. Run: pip install playwright && playwright install chromium")
|
|
|
|
needs_init = (
|
|
not self._initialized
|
|
or self._page is None
|
|
or self._browser is None
|
|
or self._cdp_endpoint != cdp_endpoint
|
|
or self._device != device
|
|
)
|
|
|
|
if needs_init:
|
|
if self._initialized:
|
|
await self.close()
|
|
|
|
self._playwright = await async_playwright().start()
|
|
self._headless = headless
|
|
self._cdp_endpoint = cdp_endpoint
|
|
self._device = device
|
|
self._state = BrowserState()
|
|
|
|
device_settings = DEVICES.get(device) if device else None
|
|
|
|
if cdp_endpoint:
|
|
logger.info(f"Connecting to browser at {cdp_endpoint}")
|
|
self._browser = await self._playwright.chromium.connect_over_cdp(cdp_endpoint)
|
|
contexts = self._browser.contexts
|
|
if contexts:
|
|
self._context = contexts[0]
|
|
pages = self._context.pages
|
|
if pages:
|
|
self._page = pages[0]
|
|
self._pages = list(pages)
|
|
else:
|
|
self._page = await self._context.new_page()
|
|
self._pages = [self._page]
|
|
else:
|
|
context_opts = {"viewport": {"width": 1280, "height": 720}}
|
|
if device_settings:
|
|
context_opts.update(device_settings)
|
|
self._context = await self._browser.new_context(**context_opts)
|
|
self._page = await self._context.new_page()
|
|
self._pages = [self._page]
|
|
else:
|
|
self._browser = await self._playwright.chromium.launch(
|
|
headless=headless,
|
|
args=[
|
|
"--disable-blink-features=AutomationControlled",
|
|
"--no-sandbox",
|
|
],
|
|
)
|
|
|
|
context_opts = {
|
|
"viewport": {"width": 1440, "height": 900},
|
|
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
}
|
|
if device_settings:
|
|
context_opts.update(device_settings)
|
|
|
|
self._context = await self._browser.new_context(**context_opts)
|
|
self._contexts = [self._context]
|
|
self._page = await self._context.new_page()
|
|
self._pages = [self._page]
|
|
|
|
self._setup_page_listeners(self._page)
|
|
self._initialized = True
|
|
logger.info(f"Browser initialized (device={device or 'laptop'})")
|
|
|
|
return self._page
|
|
|
|
async def new_context(self, device: Optional[str] = None, **kwargs) -> BrowserContext:
|
|
"""Create a new isolated browser context for parallel agents."""
|
|
if not self._browser:
|
|
raise RuntimeError("Browser not initialized")
|
|
|
|
context_opts = {}
|
|
if device and device in DEVICES:
|
|
context_opts.update(DEVICES[device])
|
|
context_opts.update(kwargs)
|
|
|
|
context = await self._browser.new_context(**context_opts)
|
|
self._contexts.append(context)
|
|
return context
|
|
|
|
async def new_page(self, url: Optional[str] = None, context: Optional[BrowserContext] = None) -> Page:
|
|
"""Open a new page in specified or current context."""
|
|
ctx = context or self._context
|
|
if not ctx:
|
|
raise RuntimeError("Browser not initialized")
|
|
page = await ctx.new_page()
|
|
self._setup_page_listeners(page)
|
|
self._pages.append(page)
|
|
self._page = page
|
|
if url:
|
|
await page.goto(url)
|
|
return page
|
|
|
|
async def close_page(self, index: Optional[int] = None) -> None:
|
|
"""Close a page by index (default: current page)."""
|
|
if not self._pages:
|
|
return
|
|
|
|
idx = index if index is not None else self._pages.index(self._page) if self._page in self._pages else -1
|
|
if 0 <= idx < len(self._pages):
|
|
page = self._pages.pop(idx)
|
|
await page.close()
|
|
if self._pages:
|
|
self._page = self._pages[min(idx, len(self._pages) - 1)]
|
|
else:
|
|
self._page = None
|
|
|
|
async def switch_page(self, index: int) -> Page:
|
|
"""Switch to page by index."""
|
|
if 0 <= index < len(self._pages):
|
|
self._page = self._pages[index]
|
|
await self._page.bring_to_front()
|
|
return self._page
|
|
raise ValueError(f"Invalid page index: {index}")
|
|
|
|
async def close(self) -> None:
|
|
"""Close browser and cleanup."""
|
|
if self._state.tracing and self._context:
|
|
try:
|
|
await self._context.tracing.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
if self._browser:
|
|
try:
|
|
await self._browser.close()
|
|
except Exception as e:
|
|
logger.warning(f"Error closing browser: {e}")
|
|
|
|
if self._playwright:
|
|
try:
|
|
await self._playwright.stop()
|
|
except Exception as e:
|
|
logger.warning(f"Error stopping playwright: {e}")
|
|
|
|
self._browser = None
|
|
self._context = None
|
|
self._page = None
|
|
self._pages = []
|
|
self._contexts = []
|
|
self._playwright = None
|
|
self._initialized = False
|
|
self._state = BrowserState()
|
|
logger.info("Browser closed")
|
|
|
|
@property
|
|
def page(self) -> Optional[Page]:
|
|
return self._page
|
|
|
|
@property
|
|
def pages(self) -> list[Page]:
|
|
return self._pages
|
|
|
|
@property
|
|
def state(self) -> BrowserState:
|
|
return self._state
|
|
|
|
|
|
class BrowserTool(BaseTool):
|
|
"""Complete browser automation with full Playwright API surface area.
|
|
|
|
PARALLEL AGENTS:
|
|
Use `new_context` action to create isolated sessions.
|
|
Each context has separate cookies, storage, and cache.
|
|
One Chrome process, many parallel agent sessions.
|
|
|
|
DEVICES:
|
|
- mobile, tablet, laptop (user-friendly)
|
|
- iphone_14, iphone_15_pro, pixel_7, galaxy_s23, ipad_pro (specific)
|
|
"""
|
|
|
|
name = "browser"
|
|
|
|
def __init__(self, headless: bool = True, cdp_endpoint: Optional[str] = None):
|
|
self.headless = headless
|
|
self.cdp_endpoint = cdp_endpoint or os.environ.get("BROWSER_CDP_ENDPOINT")
|
|
self.timeout = 30000
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return """Complete browser automation with full Playwright API.
|
|
|
|
DISPLAY INSTRUCTIONS: Show results as bullet points.
|
|
• navigate: Navigated to [url] (status: [status])
|
|
• click/tap: [action] on [selector]
|
|
• expect_*: ✓ Assertion passed / ✗ Assertion failed
|
|
• screenshot: Captured [size] bytes
|
|
|
|
PARALLEL AGENTS:
|
|
- Use `new_context` for isolated sessions
|
|
- One Chrome, many parallel agent contexts
|
|
|
|
DEVICES: mobile, tablet, laptop, iphone_14, pixel_7, ipad_pro
|
|
|
|
CATEGORIES:
|
|
- Navigation: navigate, set_content, content, url, title, reload, go_back/forward
|
|
- Input: click, dblclick, type, fill, clear, press
|
|
- Forms: select_option, check, uncheck, upload
|
|
- Mouse: hover, drag, mouse_move/down/up, mouse_wheel, scroll
|
|
- Touch: tap, swipe, pinch
|
|
- Locators: locator, get_by_role/text/label/placeholder/test_id/alt_text/title
|
|
- Composition: first, last, nth, filter, all, count
|
|
- Content: get_text, get_inner_text, get_attribute, get_value, get_html, get_bounding_box
|
|
- State: is_visible/hidden/enabled/editable/checked
|
|
- Assertions: expect_visible/hidden/enabled/text/value/checked/url/title/count/attribute
|
|
- Wait: wait, wait_for_load/url/event/request/response/function
|
|
- Page: screenshot, pdf, snapshot, evaluate, focus, blur
|
|
- Device: viewport, emulate, geolocation, permissions
|
|
- Network: route (mock/block), unroute
|
|
- Storage: cookies, clear_cookies, storage, storage_state
|
|
- Events: on, off
|
|
- Dialogs: dialog
|
|
- Files: file_chooser, download
|
|
- Browser: new_page, new_context, new_tab, close_tab, tabs, status
|
|
- Debug: trace_start/stop, highlight, console, errors
|
|
"""
|
|
|
|
async def _get_page(self, device: Optional[str] = None) -> Page:
|
|
"""Get page from shared pool."""
|
|
pool = await BrowserPool.get_instance()
|
|
return await pool.ensure_browser(
|
|
headless=self.headless,
|
|
cdp_endpoint=self.cdp_endpoint,
|
|
device=device,
|
|
)
|
|
|
|
def _get_locator(self, page: Page, selector: str, frame: Optional[str] = None) -> Locator:
|
|
"""Get a locator, optionally within a frame."""
|
|
if frame:
|
|
return page.frame_locator(frame).locator(selector)
|
|
return page.locator(selector)
|
|
|
|
async def call(self, ctx, action: str, **kwargs) -> dict[str, Any]:
|
|
"""Execute browser action."""
|
|
return await self.execute(action=action, **kwargs)
|
|
|
|
async def execute(
|
|
self,
|
|
action: str,
|
|
# Selectors
|
|
url: Optional[str] = None,
|
|
selector: Optional[str] = None,
|
|
ref: Optional[str] = None,
|
|
target_selector: Optional[str] = None,
|
|
# Text/Values
|
|
text: Optional[str] = None,
|
|
value: Optional[str] = None,
|
|
key: Optional[str] = None,
|
|
code: Optional[str] = None,
|
|
html: Optional[str] = None,
|
|
attribute: Optional[str] = None,
|
|
# Locator options
|
|
role: Optional[str] = None,
|
|
name: Optional[str] = None,
|
|
exact: bool = False,
|
|
# Locator composition
|
|
index: Optional[int] = None,
|
|
has_text: Optional[str] = None,
|
|
has_not_text: Optional[str] = None,
|
|
has: Optional[str] = None, # Nested selector
|
|
# Files
|
|
files: Optional[list[str]] = None,
|
|
# Mouse/Touch
|
|
x: Optional[int] = None,
|
|
y: Optional[int] = None,
|
|
button: Optional[str] = None,
|
|
delta_x: Optional[int] = None,
|
|
delta_y: Optional[int] = None,
|
|
direction: Optional[str] = None,
|
|
distance: Optional[int] = None,
|
|
scale: Optional[float] = None,
|
|
# Viewport/Device
|
|
width: Optional[int] = None,
|
|
height: Optional[int] = None,
|
|
device: Optional[str] = None,
|
|
# Geolocation
|
|
latitude: Optional[float] = None,
|
|
longitude: Optional[float] = None,
|
|
accuracy: Optional[float] = None,
|
|
# Permissions
|
|
permission: Optional[str] = None,
|
|
# Network
|
|
pattern: Optional[str] = None,
|
|
response: Optional[Union[dict, str]] = None,
|
|
status_code: Optional[int] = None,
|
|
block: bool = False,
|
|
# Wait/Assert options
|
|
state: Optional[str] = None,
|
|
event: Optional[str] = None,
|
|
expected: Optional[str] = None,
|
|
not_: bool = False, # For negative assertions
|
|
# Options
|
|
timeout: Optional[int] = None,
|
|
full_page: bool = False,
|
|
tab_index: Optional[int] = None,
|
|
cdp_endpoint: Optional[str] = None,
|
|
headless: Optional[bool] = None,
|
|
# Storage
|
|
cookies: Optional[list[dict]] = None,
|
|
storage_type: Optional[str] = None,
|
|
storage_data: Optional[dict] = None,
|
|
# Auth
|
|
auth_file: Optional[str] = None,
|
|
# Dialog
|
|
accept: bool = True,
|
|
prompt_text: Optional[str] = None,
|
|
# Frame
|
|
frame: Optional[str] = None,
|
|
# Trace
|
|
trace_path: Optional[str] = None,
|
|
# Filter
|
|
level: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
"""Execute browser action with full Playwright API support."""
|
|
pool = await BrowserPool.get_instance()
|
|
timeout = timeout or self.timeout
|
|
sel = selector or ref
|
|
|
|
try:
|
|
# === Connection ===
|
|
if action == "connect":
|
|
endpoint = cdp_endpoint or self.cdp_endpoint
|
|
if not endpoint:
|
|
return {"error": "cdp_endpoint required"}
|
|
page = await pool.ensure_browser(headless=self.headless, cdp_endpoint=endpoint)
|
|
return {"success": True, "connected": True, "endpoint": endpoint, "url": page.url}
|
|
|
|
# === Device Emulation ===
|
|
if action == "emulate":
|
|
if not device:
|
|
return {"error": f"device required. Available: {list(DEVICES.keys())}"}
|
|
if device not in DEVICES:
|
|
return {"error": f"Unknown device. Available: {list(DEVICES.keys())}"}
|
|
page = await pool.ensure_browser(headless=self.headless, cdp_endpoint=self.cdp_endpoint, device=device)
|
|
settings = DEVICES[device]
|
|
return {"success": True, "device": device, **settings}
|
|
|
|
page = await self._get_page(device)
|
|
|
|
# === Core Page Navigation & Lifecycle ===
|
|
if action == "navigate":
|
|
if not url:
|
|
return {"error": "url required"}
|
|
resp = await page.goto(url, timeout=timeout, wait_until=state or "domcontentloaded")
|
|
return {
|
|
"success": True,
|
|
"url": page.url,
|
|
"title": await page.title(),
|
|
"status": resp.status if resp else None,
|
|
}
|
|
|
|
elif action == "set_content":
|
|
if not html:
|
|
return {"error": "html required"}
|
|
await page.set_content(html, timeout=timeout)
|
|
return {"success": True, "set_content": True}
|
|
|
|
elif action == "content":
|
|
return {"success": True, "html": await page.content()}
|
|
|
|
elif action == "url":
|
|
return {"success": True, "url": page.url}
|
|
|
|
elif action == "title":
|
|
return {"success": True, "title": await page.title()}
|
|
|
|
elif action == "reload":
|
|
resp = await page.reload(timeout=timeout)
|
|
return {"success": True, "url": page.url, "status": resp.status if resp else None}
|
|
|
|
elif action == "go_back":
|
|
resp = await page.go_back(timeout=timeout)
|
|
return {"success": True, "url": page.url, "navigated": resp is not None}
|
|
|
|
elif action == "go_forward":
|
|
resp = await page.go_forward(timeout=timeout)
|
|
return {"success": True, "url": page.url, "navigated": resp is not None}
|
|
|
|
# === Input ===
|
|
elif action == "click":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.click(timeout=timeout, button=button or "left")
|
|
return {"success": True, "clicked": sel}
|
|
|
|
elif action == "dblclick":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.dblclick(timeout=timeout)
|
|
return {"success": True, "double_clicked": sel}
|
|
|
|
elif action == "type":
|
|
if not sel or text is None:
|
|
return {"error": "selector and text required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.type(text, timeout=timeout)
|
|
return {"success": True, "typed": len(text), "selector": sel}
|
|
|
|
elif action == "fill":
|
|
if not sel or text is None:
|
|
return {"error": "selector and text required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.fill(text, timeout=timeout)
|
|
return {"success": True, "filled": sel}
|
|
|
|
elif action == "clear":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.clear(timeout=timeout)
|
|
return {"success": True, "cleared": sel}
|
|
|
|
elif action == "press":
|
|
if not key:
|
|
return {"error": "key required"}
|
|
if sel:
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.press(key, timeout=timeout)
|
|
else:
|
|
await page.keyboard.press(key)
|
|
return {"success": True, "pressed": key}
|
|
|
|
# === Forms ===
|
|
elif action == "select_option":
|
|
if not sel or value is None:
|
|
return {"error": "selector and value required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
selected = await loc.select_option(value if isinstance(value, list) else [value], timeout=timeout)
|
|
return {"success": True, "selected": selected}
|
|
|
|
elif action == "check":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.check(timeout=timeout)
|
|
return {"success": True, "checked": sel}
|
|
|
|
elif action == "uncheck":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.uncheck(timeout=timeout)
|
|
return {"success": True, "unchecked": sel}
|
|
|
|
elif action == "upload":
|
|
if not sel or not files:
|
|
return {"error": "selector and files required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.set_input_files(files, timeout=timeout)
|
|
return {"success": True, "uploaded": len(files)}
|
|
|
|
# === Mouse ===
|
|
elif action == "hover":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.hover(timeout=timeout)
|
|
return {"success": True, "hovered": sel}
|
|
|
|
elif action == "drag":
|
|
if not sel or not target_selector:
|
|
return {"error": "selector and target_selector required"}
|
|
await page.drag_and_drop(sel, target_selector, timeout=timeout)
|
|
return {"success": True, "dragged": sel, "to": target_selector}
|
|
|
|
elif action == "mouse_move":
|
|
if x is None or y is None:
|
|
return {"error": "x and y required"}
|
|
await page.mouse.move(x, y)
|
|
return {"success": True, "moved_to": {"x": x, "y": y}}
|
|
|
|
elif action == "mouse_down":
|
|
await page.mouse.down(button=button or "left")
|
|
return {"success": True, "button_down": button or "left"}
|
|
|
|
elif action == "mouse_up":
|
|
await page.mouse.up(button=button or "left")
|
|
return {"success": True, "button_up": button or "left"}
|
|
|
|
elif action == "mouse_wheel":
|
|
await page.mouse.wheel(delta_x or 0, delta_y or 0)
|
|
return {"success": True, "scrolled": {"delta_x": delta_x or 0, "delta_y": delta_y or 0}}
|
|
|
|
elif action == "scroll":
|
|
if sel:
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.scroll_into_view_if_needed(timeout=timeout)
|
|
return {"success": True, "scrolled_to": sel}
|
|
else:
|
|
await page.evaluate(f"window.scrollBy({delta_x or 0}, {delta_y or 300})")
|
|
return {"success": True, "scrolled": {"delta_x": delta_x or 0, "delta_y": delta_y or 300}}
|
|
|
|
# === Touch ===
|
|
elif action == "tap":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.tap(timeout=timeout)
|
|
return {"success": True, "tapped": sel}
|
|
|
|
elif action == "swipe":
|
|
if not sel or not direction:
|
|
return {"error": "selector and direction required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
box = await loc.bounding_box()
|
|
if not box:
|
|
return {"error": "Element not visible"}
|
|
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
|
|
dist = distance or 200
|
|
offsets = {"left": (-dist, 0), "right": (dist, 0), "up": (0, -dist), "down": (0, dist)}
|
|
dx, dy = offsets.get(direction, (0, 0))
|
|
await page.touchscreen.tap(cx, cy)
|
|
await page.mouse.move(cx, cy)
|
|
await page.mouse.down()
|
|
await page.mouse.move(cx + dx, cy + dy, steps=10)
|
|
await page.mouse.up()
|
|
return {"success": True, "swiped": sel, "direction": direction}
|
|
|
|
elif action == "pinch":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
zoom = scale or 0.5
|
|
await page.evaluate(
|
|
f"""(sel) => {{
|
|
const el = document.querySelector(sel);
|
|
if (el) el.dispatchEvent(new WheelEvent('wheel', {{deltaY: {"-100" if zoom > 1 else "100"}, ctrlKey: true, bubbles: true}}));
|
|
}}""",
|
|
sel,
|
|
)
|
|
return {"success": True, "pinched": sel, "scale": zoom}
|
|
|
|
# === Locator Creation ===
|
|
elif action == "locator":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
cnt = await loc.count()
|
|
return {
|
|
"success": True,
|
|
"selector": sel,
|
|
"count": cnt,
|
|
"visible": await loc.first.is_visible() if cnt > 0 else False,
|
|
}
|
|
|
|
elif action == "frame_locator":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
# Just validate frame exists
|
|
frame_loc = page.frame_locator(sel)
|
|
return {"success": True, "frame": sel, "note": "Use frame parameter in subsequent actions"}
|
|
|
|
# === Built-in Locators ===
|
|
elif action == "get_by_role":
|
|
if not role:
|
|
return {"error": "role required"}
|
|
loc = page.get_by_role(role, name=name, exact=exact)
|
|
cnt = await loc.count()
|
|
return {"success": True, "role": role, "name": name, "count": cnt}
|
|
|
|
elif action == "get_by_text":
|
|
if not text:
|
|
return {"error": "text required"}
|
|
loc = page.get_by_text(text, exact=exact)
|
|
return {"success": True, "text": text, "count": await loc.count()}
|
|
|
|
elif action == "get_by_label":
|
|
if not text:
|
|
return {"error": "text required"}
|
|
loc = page.get_by_label(text, exact=exact)
|
|
return {"success": True, "label": text, "count": await loc.count()}
|
|
|
|
elif action == "get_by_placeholder":
|
|
if not text:
|
|
return {"error": "text required"}
|
|
loc = page.get_by_placeholder(text, exact=exact)
|
|
return {"success": True, "placeholder": text, "count": await loc.count()}
|
|
|
|
elif action == "get_by_test_id":
|
|
if not text:
|
|
return {"error": "text required"}
|
|
loc = page.get_by_test_id(text)
|
|
return {"success": True, "test_id": text, "count": await loc.count()}
|
|
|
|
elif action == "get_by_alt_text":
|
|
if not text:
|
|
return {"error": "text required"}
|
|
loc = page.get_by_alt_text(text, exact=exact)
|
|
return {"success": True, "alt_text": text, "count": await loc.count()}
|
|
|
|
elif action == "get_by_title":
|
|
if not text:
|
|
return {"error": "text required"}
|
|
loc = page.get_by_title(text, exact=exact)
|
|
return {"success": True, "title": text, "count": await loc.count()}
|
|
|
|
# === Locator Composition ===
|
|
elif action == "first":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame).first
|
|
visible = await loc.is_visible()
|
|
return {"success": True, "first": True, "visible": visible}
|
|
|
|
elif action == "last":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame).last
|
|
visible = await loc.is_visible()
|
|
return {"success": True, "last": True, "visible": visible}
|
|
|
|
elif action == "nth":
|
|
if not sel or index is None:
|
|
return {"error": "selector and index required"}
|
|
loc = self._get_locator(page, sel, frame).nth(index)
|
|
visible = await loc.is_visible()
|
|
return {"success": True, "nth": index, "visible": visible}
|
|
|
|
elif action == "filter":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
filter_opts = {}
|
|
if has_text:
|
|
filter_opts["has_text"] = has_text
|
|
if has_not_text:
|
|
filter_opts["has_not_text"] = has_not_text
|
|
if has:
|
|
filter_opts["has"] = page.locator(has)
|
|
if filter_opts:
|
|
loc = loc.filter(**filter_opts)
|
|
return {"success": True, "filtered": True, "count": await loc.count()}
|
|
|
|
elif action == "all":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
elements = await loc.all()
|
|
results = []
|
|
for i, el in enumerate(elements):
|
|
results.append(
|
|
{
|
|
"index": i,
|
|
"visible": await el.is_visible(),
|
|
"text": await el.text_content(),
|
|
}
|
|
)
|
|
return {"success": True, "count": len(results), "elements": results[:20]} # Limit to 20
|
|
|
|
elif action == "count":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "count": await loc.count()}
|
|
|
|
# === Content Extraction ===
|
|
elif action == "get_text":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "text": await loc.text_content(timeout=timeout)}
|
|
|
|
elif action == "get_inner_text":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "inner_text": await loc.inner_text(timeout=timeout)}
|
|
|
|
elif action == "get_attribute":
|
|
if not sel or not attribute:
|
|
return {"error": "selector and attribute required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {
|
|
"success": True,
|
|
"attribute": attribute,
|
|
"value": await loc.get_attribute(attribute, timeout=timeout),
|
|
}
|
|
|
|
elif action == "get_value":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "value": await loc.input_value(timeout=timeout)}
|
|
|
|
elif action == "get_html":
|
|
if sel:
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "html": await loc.inner_html(timeout=timeout)}
|
|
return {"success": True, "html": await page.content()}
|
|
|
|
elif action == "get_bounding_box":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
box = await loc.bounding_box(timeout=timeout)
|
|
return {"success": True, "bounding_box": box} if box else {"error": "Element not visible"}
|
|
|
|
# === State Checks ===
|
|
elif action == "is_visible":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "visible": await loc.is_visible(timeout=timeout)}
|
|
|
|
elif action == "is_hidden":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "hidden": await loc.is_hidden(timeout=timeout)}
|
|
|
|
elif action == "is_enabled":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "enabled": await loc.is_enabled(timeout=timeout)}
|
|
|
|
elif action == "is_editable":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "editable": await loc.is_editable(timeout=timeout)}
|
|
|
|
elif action == "is_checked":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
return {"success": True, "checked": await loc.is_checked(timeout=timeout)}
|
|
|
|
# === Assertions (expect) ===
|
|
elif action.startswith("expect_"):
|
|
from playwright.async_api import expect
|
|
|
|
if action == "expect_url":
|
|
pattern = expected or url or pattern
|
|
if not pattern:
|
|
return {"error": "expected URL pattern required"}
|
|
try:
|
|
await expect(page).to_have_url(
|
|
re.compile(pattern) if "*" in pattern else pattern, timeout=timeout
|
|
)
|
|
return {"success": True, "assertion": "url", "passed": True}
|
|
except Exception as e:
|
|
return {"success": False, "assertion": "url", "passed": False, "error": str(e)}
|
|
|
|
elif action == "expect_title":
|
|
pattern = expected or text
|
|
if not pattern:
|
|
return {"error": "expected title required"}
|
|
try:
|
|
await expect(page).to_have_title(
|
|
re.compile(pattern) if "*" in pattern else pattern, timeout=timeout
|
|
)
|
|
return {"success": True, "assertion": "title", "passed": True}
|
|
except Exception as e:
|
|
return {"success": False, "assertion": "title", "passed": False, "error": str(e)}
|
|
|
|
elif not sel:
|
|
return {"error": "selector required for element assertions"}
|
|
|
|
loc = self._get_locator(page, sel, frame)
|
|
assertion_type = action.replace("expect_", "")
|
|
|
|
try:
|
|
if assertion_type == "visible":
|
|
if not_:
|
|
await expect(loc).not_to_be_visible(timeout=timeout)
|
|
else:
|
|
await expect(loc).to_be_visible(timeout=timeout)
|
|
elif assertion_type == "hidden":
|
|
if not_:
|
|
await expect(loc).not_to_be_hidden(timeout=timeout)
|
|
else:
|
|
await expect(loc).to_be_hidden(timeout=timeout)
|
|
elif assertion_type == "enabled":
|
|
if not_:
|
|
await expect(loc).not_to_be_enabled(timeout=timeout)
|
|
else:
|
|
await expect(loc).to_be_enabled(timeout=timeout)
|
|
elif assertion_type == "text":
|
|
if not expected and not text:
|
|
return {"error": "expected text required"}
|
|
exp = expected or text
|
|
if not_:
|
|
await expect(loc).not_to_have_text(exp, timeout=timeout)
|
|
else:
|
|
await expect(loc).to_have_text(exp, timeout=timeout)
|
|
elif assertion_type == "value":
|
|
if not expected and not value:
|
|
return {"error": "expected value required"}
|
|
exp = expected or value
|
|
if not_:
|
|
await expect(loc).not_to_have_value(exp, timeout=timeout)
|
|
else:
|
|
await expect(loc).to_have_value(exp, timeout=timeout)
|
|
elif assertion_type == "checked":
|
|
if not_:
|
|
await expect(loc).not_to_be_checked(timeout=timeout)
|
|
else:
|
|
await expect(loc).to_be_checked(timeout=timeout)
|
|
elif assertion_type == "count":
|
|
if index is None:
|
|
return {"error": "index (expected count) required"}
|
|
await expect(loc).to_have_count(index, timeout=timeout)
|
|
elif assertion_type == "attribute":
|
|
if not attribute or not expected:
|
|
return {"error": "attribute and expected required"}
|
|
if not_:
|
|
await expect(loc).not_to_have_attribute(attribute, expected, timeout=timeout)
|
|
else:
|
|
await expect(loc).to_have_attribute(attribute, expected, timeout=timeout)
|
|
else:
|
|
return {"error": f"Unknown assertion: {assertion_type}"}
|
|
|
|
return {"success": True, "assertion": assertion_type, "passed": True, "selector": sel}
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"assertion": assertion_type,
|
|
"passed": False,
|
|
"selector": sel,
|
|
"error": str(e),
|
|
}
|
|
|
|
# === Page Actions ===
|
|
elif action == "screenshot":
|
|
opts = {"full_page": full_page, "type": "png"}
|
|
if sel:
|
|
loc = self._get_locator(page, sel, frame)
|
|
data = await loc.screenshot(**opts)
|
|
else:
|
|
data = await page.screenshot(**opts)
|
|
return {"success": True, "format": "png", "size": len(data), "base64": base64.b64encode(data).decode()}
|
|
|
|
elif action == "pdf":
|
|
data = await page.pdf()
|
|
return {"success": True, "format": "pdf", "size": len(data), "base64": base64.b64encode(data).decode()}
|
|
|
|
elif action == "snapshot":
|
|
return {
|
|
"success": True,
|
|
"url": page.url,
|
|
"title": await page.title(),
|
|
"snapshot": await page.accessibility.snapshot(),
|
|
}
|
|
|
|
elif action == "evaluate":
|
|
if not code:
|
|
return {"error": "code required"}
|
|
result = await page.evaluate(code)
|
|
return {"success": True, "result": result}
|
|
|
|
elif action == "focus":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.focus(timeout=timeout)
|
|
return {"success": True, "focused": sel}
|
|
|
|
elif action == "blur":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.blur(timeout=timeout)
|
|
return {"success": True, "blurred": sel}
|
|
|
|
elif action == "highlight":
|
|
if not sel:
|
|
return {"error": "selector required"}
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.highlight()
|
|
return {"success": True, "highlighted": sel}
|
|
|
|
# === Wait Primitives ===
|
|
elif action == "wait":
|
|
if sel:
|
|
loc = self._get_locator(page, sel, frame)
|
|
await loc.wait_for(timeout=timeout, state=state or "visible")
|
|
return {"success": True, "found": sel}
|
|
elif timeout:
|
|
await asyncio.sleep(timeout / 1000)
|
|
return {"success": True, "waited_ms": timeout}
|
|
return {"error": "selector or timeout required"}
|
|
|
|
elif action == "wait_for_load":
|
|
await page.wait_for_load_state(state or "load", timeout=timeout)
|
|
return {"success": True, "state": state or "load"}
|
|
|
|
elif action == "wait_for_url":
|
|
if not pattern and not url:
|
|
return {"error": "pattern or url required"}
|
|
await page.wait_for_url(pattern or url, timeout=timeout)
|
|
return {"success": True, "url": page.url}
|
|
|
|
elif action == "wait_for_event":
|
|
if not event:
|
|
return {"error": "event required (request, response, download, filechooser, popup)"}
|
|
result = await page.wait_for_event(event, timeout=timeout)
|
|
if event == "request":
|
|
return {"success": True, "event": event, "url": result.url, "method": result.method}
|
|
elif event == "response":
|
|
return {"success": True, "event": event, "url": result.url, "status": result.status}
|
|
elif event == "download":
|
|
return {"success": True, "event": event, "filename": result.suggested_filename}
|
|
return {"success": True, "event": event}
|
|
|
|
elif action == "wait_for_request":
|
|
if not pattern:
|
|
return {"error": "pattern required"}
|
|
req = await page.wait_for_request(pattern, timeout=timeout)
|
|
return {"success": True, "url": req.url, "method": req.method}
|
|
|
|
elif action == "wait_for_response":
|
|
if not pattern:
|
|
return {"error": "pattern required"}
|
|
resp = await page.wait_for_response(pattern, timeout=timeout)
|
|
return {"success": True, "url": resp.url, "status": resp.status}
|
|
|
|
elif action == "wait_for_function":
|
|
if not code:
|
|
return {"error": "code (JavaScript function) required"}
|
|
await page.wait_for_function(code, timeout=timeout)
|
|
return {"success": True, "function_returned_truthy": True}
|
|
|
|
# === Viewport/Device ===
|
|
elif action == "viewport":
|
|
if width is None or height is None:
|
|
return {"success": True, "viewport": page.viewport_size}
|
|
await page.set_viewport_size({"width": width, "height": height})
|
|
return {"success": True, "viewport": {"width": width, "height": height}}
|
|
|
|
elif action == "geolocation":
|
|
if latitude is None or longitude is None:
|
|
return {"error": "latitude and longitude required"}
|
|
await pool._context.set_geolocation(
|
|
{"latitude": latitude, "longitude": longitude, "accuracy": accuracy or 100}
|
|
)
|
|
return {"success": True, "geolocation": {"lat": latitude, "lon": longitude}}
|
|
|
|
elif action == "permissions":
|
|
if not permission:
|
|
return {"error": "permission required"}
|
|
await pool._context.grant_permissions([permission])
|
|
return {"success": True, "granted": permission}
|
|
|
|
# === Network ===
|
|
elif action == "route":
|
|
if not pattern:
|
|
return {"error": "pattern required"}
|
|
|
|
async def handle(route: Route):
|
|
if block:
|
|
await route.abort()
|
|
elif response:
|
|
body = json.dumps(response) if isinstance(response, dict) else response
|
|
await route.fulfill(status=status_code or 200, content_type="application/json", body=body)
|
|
else:
|
|
await route.continue_()
|
|
|
|
await page.route(pattern, handle)
|
|
pool._state.routes[pattern] = {"block": block, "mock": response is not None}
|
|
return {"success": True, "route": pattern}
|
|
|
|
elif action == "unroute":
|
|
if not pattern:
|
|
return {"error": "pattern required"}
|
|
await page.unroute(pattern)
|
|
pool._state.routes.pop(pattern, None)
|
|
return {"success": True, "unrouted": pattern}
|
|
|
|
# === Storage ===
|
|
elif action == "cookies":
|
|
if cookies:
|
|
await pool._context.add_cookies(cookies)
|
|
return {"success": True, "set_cookies": len(cookies)}
|
|
return {"success": True, "cookies": await pool._context.cookies()}
|
|
|
|
elif action == "clear_cookies":
|
|
await pool._context.clear_cookies()
|
|
return {"success": True, "cleared_cookies": True}
|
|
|
|
elif action == "storage":
|
|
st = storage_type or "local"
|
|
store = "localStorage" if st == "local" else "sessionStorage"
|
|
if storage_data:
|
|
for k, v in storage_data.items():
|
|
await page.evaluate(
|
|
f"{store}.setItem('{k}', '{json.dumps(v) if isinstance(v, (dict, list)) else v}')"
|
|
)
|
|
return {"success": True, "set_keys": list(storage_data.keys())}
|
|
return {"success": True, "data": await page.evaluate(f"Object.fromEntries(Object.entries({store}))")}
|
|
|
|
elif action == "storage_state":
|
|
if not auth_file:
|
|
return {"error": "auth_file required"}
|
|
path = Path(auth_file)
|
|
if path.exists():
|
|
storage = json.loads(path.read_text())
|
|
await pool._context.add_cookies(storage.get("cookies", []))
|
|
return {"success": True, "loaded": auth_file}
|
|
storage_state = await pool._context.storage_state()
|
|
path.write_text(json.dumps(storage_state, indent=2))
|
|
return {"success": True, "saved": auth_file}
|
|
|
|
# === Events ===
|
|
elif action == "on":
|
|
if not event:
|
|
return {"error": "event required"}
|
|
# Events are auto-handled by _setup_page_listeners
|
|
return {
|
|
"success": True,
|
|
"listening": event,
|
|
"note": "Use console/errors/dialog actions to retrieve captured events",
|
|
}
|
|
|
|
elif action == "off":
|
|
return {"success": True, "note": "Event listeners managed automatically"}
|
|
|
|
# === Dialogs ===
|
|
elif action == "dialog":
|
|
if pool._state.pending_dialog:
|
|
d = pool._state.pending_dialog
|
|
if accept:
|
|
await d.accept(prompt_text or "")
|
|
else:
|
|
await d.dismiss()
|
|
pool._state.pending_dialog = None
|
|
return {"success": True, "type": d.type, "message": d.message, "accepted": accept}
|
|
return {"error": "No pending dialog"}
|
|
|
|
# === Frames ===
|
|
elif action == "frame":
|
|
if not sel:
|
|
return {"error": "selector required for frame"}
|
|
return {"success": True, "frame": sel, "note": "Use frame parameter in subsequent actions"}
|
|
|
|
elif action == "main_frame":
|
|
return {"success": True, "frame": "main"}
|
|
|
|
# === File Chooser & Downloads ===
|
|
elif action == "file_chooser":
|
|
if pool._state.pending_file_chooser:
|
|
fc = pool._state.pending_file_chooser
|
|
if files:
|
|
await fc.set_files(files)
|
|
pool._state.pending_file_chooser = None
|
|
return {"success": True, "uploaded": len(files)}
|
|
return {"success": True, "file_chooser_pending": True, "multiple": fc.is_multiple}
|
|
return {"error": "No pending file chooser. Trigger an upload first."}
|
|
|
|
elif action == "download":
|
|
if pool._state.pending_download:
|
|
d = pool._state.pending_download
|
|
path = await d.path()
|
|
pool._state.pending_download = None
|
|
return {
|
|
"success": True,
|
|
"filename": d.suggested_filename,
|
|
"path": str(path) if path else None,
|
|
"url": d.url,
|
|
}
|
|
# Trigger download by clicking
|
|
if sel:
|
|
async with page.expect_download(timeout=timeout) as dl:
|
|
await page.click(sel)
|
|
d = await dl.value
|
|
return {"success": True, "filename": d.suggested_filename, "url": d.url}
|
|
return {"error": "No pending download and no selector to click"}
|
|
|
|
# === Console/Errors ===
|
|
elif action == "console":
|
|
msgs = pool._state.console_messages
|
|
if level:
|
|
msgs = [m for m in msgs if m["type"] == level]
|
|
return {"success": True, "messages": msgs[-50:], "count": len(msgs)} # Last 50
|
|
|
|
elif action == "errors":
|
|
return {"success": True, "errors": pool._state.page_errors[-20:], "count": len(pool._state.page_errors)}
|
|
|
|
# === Browser/Context ===
|
|
elif action == "close":
|
|
await pool.close()
|
|
return {"success": True, "closed": True}
|
|
|
|
elif action == "new_page" or action == "new_tab":
|
|
new_page = await pool.new_page(url)
|
|
return {"success": True, "page_index": len(pool.pages) - 1, "url": new_page.url}
|
|
|
|
elif action == "new_context":
|
|
context = await pool.new_context(device=device)
|
|
page = await context.new_page()
|
|
pool._page = page
|
|
pool._pages.append(page)
|
|
pool._setup_page_listeners(page)
|
|
if url:
|
|
await page.goto(url)
|
|
return {"success": True, "context": "new", "device": device, "isolated": True, "url": page.url}
|
|
|
|
elif action == "close_tab":
|
|
await pool.close_page(tab_index)
|
|
return {"success": True, "remaining_pages": len(pool.pages)}
|
|
|
|
elif action == "tabs":
|
|
if tab_index is not None:
|
|
try:
|
|
page = await pool.switch_page(tab_index)
|
|
return {"success": True, "switched_to": tab_index, "url": page.url}
|
|
except ValueError as e:
|
|
return {"error": str(e)}
|
|
return {
|
|
"success": True,
|
|
"count": len(pool.pages),
|
|
"tabs": [{"index": i, "url": p.url} for i, p in enumerate(pool.pages)],
|
|
}
|
|
|
|
elif action == "set_headless":
|
|
new_headless = headless if headless is not None else not pool._headless
|
|
current_url = page.url if page else None
|
|
old_mode = "headless" if pool._headless else "headed"
|
|
await pool.close()
|
|
self.headless = new_headless
|
|
page = await pool.ensure_browser(headless=new_headless)
|
|
if current_url and current_url != "about:blank":
|
|
await page.goto(current_url)
|
|
return {
|
|
"success": True,
|
|
"previous_mode": old_mode,
|
|
"current_mode": "headless" if new_headless else "headed",
|
|
}
|
|
|
|
elif action == "status":
|
|
return {
|
|
"success": True,
|
|
"initialized": pool._initialized,
|
|
"headless": pool._headless,
|
|
"device": pool._device,
|
|
"pages": len(pool.pages),
|
|
"contexts": len(pool._contexts),
|
|
"current_url": page.url if page else None,
|
|
"console_messages": len(pool._state.console_messages),
|
|
"errors": len(pool._state.page_errors),
|
|
"routes": list(pool._state.routes.keys()),
|
|
"tracing": pool._state.tracing,
|
|
}
|
|
|
|
# === Debug ===
|
|
elif action == "trace_start":
|
|
if pool._state.tracing:
|
|
return {"error": "Tracing already active"}
|
|
await pool._context.tracing.start(screenshots=True, snapshots=True, sources=True)
|
|
pool._state.tracing = True
|
|
return {"success": True, "tracing": True}
|
|
|
|
elif action == "trace_stop":
|
|
if not pool._state.tracing:
|
|
return {"error": "Tracing not active"}
|
|
path = trace_path or f"trace-{int(asyncio.get_event_loop().time())}.zip"
|
|
await pool._context.tracing.stop(path=path)
|
|
pool._state.tracing = False
|
|
return {"success": True, "trace_path": path}
|
|
|
|
else:
|
|
return {"error": f"Unknown action: {action}"}
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Browser action failed: {action}")
|
|
return {"error": str(e), "action": action}
|
|
|
|
def register(self, mcp_server: FastMCP) -> None:
|
|
"""Register the browser tool with an MCP server."""
|
|
tool_instance = self
|
|
|
|
@mcp_server.tool(name=self.name, description=self.description)
|
|
async def browser(
|
|
action: Action,
|
|
url: Annotated[Optional[str], Field(description="URL")] = None,
|
|
selector: Annotated[Optional[str], Field(description="CSS/XPath selector")] = None,
|
|
ref: Annotated[Optional[str], Field(description="Alias for selector")] = None,
|
|
target_selector: Annotated[Optional[str], Field(description="Target for drag")] = None,
|
|
text: Annotated[Optional[str], Field(description="Text for type/fill/locators")] = None,
|
|
value: Annotated[Optional[str], Field(description="Value for select/assertions")] = None,
|
|
key: Annotated[Optional[str], Field(description="Key for press")] = None,
|
|
code: Annotated[Optional[str], Field(description="JavaScript code")] = None,
|
|
html: Annotated[Optional[str], Field(description="HTML for set_content")] = None,
|
|
attribute: Annotated[Optional[str], Field(description="Attribute name")] = None,
|
|
role: Annotated[Optional[str], Field(description="ARIA role")] = None,
|
|
name: Annotated[Optional[str], Field(description="Accessible name")] = None,
|
|
exact: Annotated[bool, Field(description="Exact text match")] = False,
|
|
index: Annotated[Optional[int], Field(description="Index for nth/count")] = None,
|
|
has_text: Annotated[Optional[str], Field(description="Filter by text")] = None,
|
|
has_not_text: Annotated[Optional[str], Field(description="Filter excluding text")] = None,
|
|
has: Annotated[Optional[str], Field(description="Filter by nested selector")] = None,
|
|
files: Annotated[Optional[list[str]], Field(description="Files for upload")] = None,
|
|
x: Annotated[Optional[int], Field(description="X coordinate")] = None,
|
|
y: Annotated[Optional[int], Field(description="Y coordinate")] = None,
|
|
button: Annotated[Optional[str], Field(description="Mouse button")] = None,
|
|
delta_x: Annotated[Optional[int], Field(description="Horizontal delta")] = None,
|
|
delta_y: Annotated[Optional[int], Field(description="Vertical delta")] = None,
|
|
direction: Annotated[Optional[str], Field(description="Swipe direction")] = None,
|
|
distance: Annotated[Optional[int], Field(description="Swipe distance")] = None,
|
|
scale: Annotated[Optional[float], Field(description="Pinch scale")] = None,
|
|
width: Annotated[Optional[int], Field(description="Viewport width")] = None,
|
|
height: Annotated[Optional[int], Field(description="Viewport height")] = None,
|
|
device: Annotated[Optional[str], Field(description="Device: mobile, tablet, laptop")] = None,
|
|
latitude: Annotated[Optional[float], Field(description="Geo latitude")] = None,
|
|
longitude: Annotated[Optional[float], Field(description="Geo longitude")] = None,
|
|
permission: Annotated[Optional[str], Field(description="Permission to grant")] = None,
|
|
pattern: Annotated[Optional[str], Field(description="URL pattern")] = None,
|
|
response: Annotated[Optional[dict], Field(description="Mock response")] = None,
|
|
status_code: Annotated[Optional[int], Field(description="Mock status")] = None,
|
|
block: Annotated[bool, Field(description="Block request")] = False,
|
|
state: Annotated[Optional[str], Field(description="Load state/wait state")] = None,
|
|
event: Annotated[Optional[str], Field(description="Event name")] = None,
|
|
expected: Annotated[Optional[str], Field(description="Expected value for assertions")] = None,
|
|
not_: Annotated[bool, Field(description="Negate assertion")] = False,
|
|
timeout: Annotated[Optional[int], Field(description="Timeout ms")] = None,
|
|
full_page: Annotated[bool, Field(description="Full page screenshot")] = False,
|
|
tab_index: Annotated[Optional[int], Field(description="Tab index")] = None,
|
|
cdp_endpoint: Annotated[Optional[str], Field(description="CDP endpoint")] = None,
|
|
headless: Annotated[Optional[bool], Field(description="Headless mode")] = None,
|
|
cookies: Annotated[Optional[list[dict]], Field(description="Cookies")] = None,
|
|
storage_type: Annotated[Optional[str], Field(description="local/session")] = None,
|
|
storage_data: Annotated[Optional[dict], Field(description="Storage data")] = None,
|
|
auth_file: Annotated[Optional[str], Field(description="Auth state file")] = None,
|
|
accept: Annotated[bool, Field(description="Accept dialog")] = True,
|
|
prompt_text: Annotated[Optional[str], Field(description="Dialog text")] = None,
|
|
frame: Annotated[Optional[str], Field(description="Frame selector")] = None,
|
|
trace_path: Annotated[Optional[str], Field(description="Trace output")] = None,
|
|
level: Annotated[Optional[str], Field(description="Console level")] = None,
|
|
) -> dict[str, Any]:
|
|
"""Complete browser automation with full Playwright API surface area."""
|
|
return await tool_instance.execute(
|
|
action=action,
|
|
url=url,
|
|
selector=selector,
|
|
ref=ref,
|
|
target_selector=target_selector,
|
|
text=text,
|
|
value=value,
|
|
key=key,
|
|
code=code,
|
|
html=html,
|
|
attribute=attribute,
|
|
role=role,
|
|
name=name,
|
|
exact=exact,
|
|
index=index,
|
|
has_text=has_text,
|
|
has_not_text=has_not_text,
|
|
has=has,
|
|
files=files,
|
|
x=x,
|
|
y=y,
|
|
button=button,
|
|
delta_x=delta_x,
|
|
delta_y=delta_y,
|
|
direction=direction,
|
|
distance=distance,
|
|
scale=scale,
|
|
width=width,
|
|
height=height,
|
|
device=device,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
permission=permission,
|
|
pattern=pattern,
|
|
response=response,
|
|
status_code=status_code,
|
|
block=block,
|
|
state=state,
|
|
event=event,
|
|
expected=expected,
|
|
not_=not_,
|
|
timeout=timeout,
|
|
full_page=full_page,
|
|
tab_index=tab_index,
|
|
cdp_endpoint=cdp_endpoint,
|
|
headless=headless,
|
|
cookies=cookies,
|
|
storage_type=storage_type,
|
|
storage_data=storage_data,
|
|
auth_file=auth_file,
|
|
accept=accept,
|
|
prompt_text=prompt_text,
|
|
frame=frame,
|
|
trace_path=trace_path,
|
|
level=level,
|
|
)
|
|
|
|
|
|
def create_browser_tool(headless: bool = True, cdp_endpoint: Optional[str] = None) -> BrowserTool:
|
|
"""Create a browser tool instance."""
|
|
return BrowserTool(headless=headless, cdp_endpoint=cdp_endpoint)
|
|
|
|
|
|
async def launch_browser_server(port: int = 9222, headless: bool = False) -> str:
|
|
"""Launch a persistent browser server for cross-MCP sharing."""
|
|
if not PLAYWRIGHT_AVAILABLE:
|
|
raise RuntimeError("Playwright not installed")
|
|
|
|
pw = await async_playwright().start()
|
|
await pw.chromium.launch(
|
|
headless=headless,
|
|
args=[
|
|
f"--remote-debugging-port={port}",
|
|
"--disable-blink-features=AutomationControlled",
|
|
"--no-sandbox",
|
|
],
|
|
)
|
|
|
|
endpoint = f"http://localhost:{port}"
|
|
logger.info(f"Browser server launched at {endpoint}")
|
|
return endpoint
|
|
|
|
|
|
# Default tool instance
|
|
browser_tool = BrowserTool()
|