# -*- coding: utf-8 -*- # flake8: noqa: E501 # pylint: disable=too-many-lines """Browser automation tool using Playwright. Single tool with action-based API matching browser MCP: start, stop, open, navigate, navigate_back, screenshot, snapshot, click, type, eval, evaluate, resize, console_messages, handle_dialog, file_upload, fill_form, install, press_key, network_requests, run_code, drag, hover, select_option, tabs, wait_for, pdf, close. Uses refs from snapshot for ref-based actions. """ import asyncio import atexit import json import logging import os import subprocess import sys import time from concurrent.futures import ThreadPoolExecutor from typing import Any, Optional from agentscope.message import TextBlock from agentscope.tool import ToolResponse from ...config import ( get_playwright_chromium_executable_path, get_system_default_browser, is_running_in_container, ) from .browser_snapshot import build_role_snapshot_from_aria logger = logging.getLogger(__name__) # Hybrid mode detection: Windows + Uvicorn reload mode requires sync Playwright # to avoid NotImplementedError with asyncio.create_subprocess_exec. # On other platforms or without reload, use async Playwright for better performance. _USE_SYNC_PLAYWRIGHT = sys.platform == "win32" and os.environ.get("COPAW_RELOAD_MODE") == "1" if _USE_SYNC_PLAYWRIGHT: _executor: Optional[ThreadPoolExecutor] = None def _get_executor() -> ThreadPoolExecutor: global _executor if _executor is None: _executor = ThreadPoolExecutor( max_workers=1, thread_name_prefix="playwright", ) return _executor async def _run_sync(func, *args, **kwargs): """Run a sync function in the thread pool and await the result.""" loop = asyncio.get_event_loop() return await loop.run_in_executor( _get_executor(), lambda: func(*args, **kwargs), ) else: async def _run_sync(func, *args, **kwargs): """Fallback: directly call async function (should not be used in async mode).""" return await func(*args, **kwargs) # Process-global browser state (one browser, multiple pages by page_id) _state: dict[str, Any] = { "playwright": None, "browser": None, "context": None, "pages": {}, "refs": {}, # page_id -> ref -> {role, name?, nth?} "refs_frame": {}, # page_id -> frame for last snapshot "console_logs": {}, # page_id -> list of {level, text} "network_requests": {}, # page_id -> list of request dicts "pending_dialogs": {}, # page_id -> dialog handlers "pending_file_choosers": {}, # page_id -> FileChooser list "headless": True, "current_page_id": None, "page_counter": 0, # monotonic counter for page_N ids, avoids reuse after close "last_activity_time": 0.0, # monotonic timestamp of last browser activity "_idle_task": None, # background asyncio.Task for idle watchdog "_last_browser_error": None, # message when launch failed (for user-facing error) "_sync_browser": None, # sync browser handle for hybrid mode "_sync_context": None, # sync context handle for hybrid mode "_sync_playwright": None, # sync playwright handle for hybrid mode } # Stop the browser after this many seconds of inactivity (default 30 minutes). _BROWSER_IDLE_TIMEOUT = 1800.0 def _touch_activity() -> None: """Record the current time as the last browser activity timestamp.""" _state["last_activity_time"] = time.monotonic() def _is_browser_running() -> bool: """Check if browser is currently running (sync or async mode).""" if _USE_SYNC_PLAYWRIGHT: return _state.get("_sync_browser") is not None return _state.get("browser") is not None def _reset_browser_state() -> None: """Reset all browser-related state variables.""" # Clear sync/async specific state _state["playwright"] = None _state["browser"] = None _state["context"] = None _state["_sync_playwright"] = None _state["_sync_browser"] = None _state["_sync_context"] = None # Clear shared state _state["pages"].clear() _state["refs"].clear() _state["refs_frame"].clear() _state["console_logs"].clear() _state["network_requests"].clear() _state["pending_dialogs"].clear() _state["pending_file_choosers"].clear() _state["current_page_id"] = None _state["page_counter"] = 0 _state["last_activity_time"] = 0.0 _state["headless"] = True async def _idle_watchdog(idle_seconds: float = _BROWSER_IDLE_TIMEOUT) -> None: """Background task: stop the browser after it has been idle for *idle_seconds*. This reclaims Chrome renderer processes that accumulate when pages are opened during agent tasks but never explicitly closed. """ try: while True: await asyncio.sleep(60) # check every minute if not _is_browser_running(): return idle = time.monotonic() - _state.get("last_activity_time", 0.0) if idle >= idle_seconds: logger.info( "Browser idle for %.0fs (limit %.0fs), stopping to release resources", idle, idle_seconds, ) await _action_stop() return except asyncio.CancelledError: pass def _atexit_cleanup() -> None: """Best-effort browser cleanup registered with :func:`atexit`. Playwright child processes are cleaned up by the OS when the parent exits, but this gives Playwright a chance to flush any pending I/O and close Chrome gracefully before the process disappears. """ if not _is_browser_running(): return try: loop = asyncio.get_event_loop() if not loop.is_running() and not loop.is_closed(): loop.run_until_complete(_action_stop()) except Exception: pass atexit.register(_atexit_cleanup) def _tool_response(text: str) -> ToolResponse: """Wrap text for agentscope Toolkit (return ToolResponse).""" return ToolResponse( content=[TextBlock(type="text", text=text)], ) def _chromium_launch_args() -> list[str]: """Extra args for Chromium when running in container.""" if is_running_in_container(): return ["--no-sandbox", "--disable-dev-shm-usage"] return [] def _chromium_executable_path() -> str | None: """Chromium executable path when set (e.g. container); else None.""" return get_playwright_chromium_executable_path() def _use_webkit_fallback() -> bool: """True only on macOS when no system Chrome/Edge/Chromium found. Use WebKit (Safari) to avoid downloading Chromium. Windows has no system WebKit, so we never use webkit there. """ return sys.platform == "darwin" and _chromium_executable_path() is None def _ensure_playwright_async(): """Import async_playwright; raise ImportError with hint if missing.""" try: from playwright.async_api import async_playwright return async_playwright except ImportError as exc: raise ImportError( "Playwright not installed. Use the same Python that runs CoPaw (e.g. " "activate your venv or use 'uv run'): " f"'{sys.executable}' -m pip install playwright && " f"'{sys.executable}' -m playwright install", ) from exc def _ensure_playwright_sync(): """Import sync_playwright; raise ImportError with hint if missing.""" try: from playwright.sync_api import sync_playwright return sync_playwright except ImportError as exc: raise ImportError( "Playwright not installed. Use the same Python that runs CoPaw (e.g. " "activate your venv or use 'uv run'): " f"'{sys.executable}' -m pip install playwright && " f"'{sys.executable}' -m playwright install", ) from exc def _sync_browser_launch(headless: bool): """Launch browser using sync Playwright (for hybrid mode).""" sync_playwright = _ensure_playwright_sync() pw = sync_playwright().start() # Start without context manager use_default = not is_running_in_container() and os.environ.get( "COPAW_BROWSER_USE_DEFAULT", "1", ).strip().lower() in ("1", "true", "yes") default_kind, default_path = get_system_default_browser() if use_default else (None, None) exe: Optional[str] = None if default_kind == "chromium" and default_path: exe = default_path elif default_kind != "webkit": exe = _chromium_executable_path() if exe: launch_kwargs = {"headless": headless} extra_args = _chromium_launch_args() if extra_args: launch_kwargs["args"] = extra_args launch_kwargs["executable_path"] = exe browser = pw.chromium.launch(**launch_kwargs) elif default_kind == "webkit" or sys.platform == "darwin": browser = pw.webkit.launch(headless=headless) else: launch_kwargs = {"headless": headless} extra_args = _chromium_launch_args() if extra_args: launch_kwargs["args"] = extra_args browser = pw.chromium.launch(**launch_kwargs) context = browser.new_context() _attach_context_listeners(context) return pw, browser, context def _sync_browser_close(): """Close browser using sync Playwright (for hybrid mode).""" if _state["_sync_browser"] is not None: try: _state["_sync_browser"].close() except Exception: pass if _state["_sync_playwright"] is not None: try: _state["_sync_playwright"].stop() except Exception: pass def _parse_json_param(value: str, default: Any = None): """Parse optional JSON string param (e.g. fields, paths, values).""" if not value or not isinstance(value, str): return default value = value.strip() if not value: return default try: return json.loads(value) except json.JSONDecodeError: if "," in value: return [x.strip() for x in value.split(",")] return default async def browser_use( # pylint: disable=R0911,R0912 action: str, url: str = "", page_id: str = "default", selector: str = "", text: str = "", code: str = "", path: str = "", wait: int = 0, full_page: bool = False, width: int = 0, height: int = 0, level: str = "info", filename: str = "", accept: bool = True, prompt_text: str = "", ref: str = "", element: str = "", paths_json: str = "", fields_json: str = "", key: str = "", submit: bool = False, slowly: bool = False, include_static: bool = False, screenshot_type: str = "png", snapshot_filename: str = "", double_click: bool = False, button: str = "left", modifiers_json: str = "", start_ref: str = "", end_ref: str = "", start_selector: str = "", end_selector: str = "", start_element: str = "", end_element: str = "", values_json: str = "", tab_action: str = "", index: int = -1, wait_time: float = 0, text_gone: str = "", frame_selector: str = "", headed: bool = False, ) -> ToolResponse: """Control browser (Playwright). Default is headless. Use headed=True with action=start to open a visible browser window. Flow: start, open(url), snapshot to get refs, then click/type etc. with ref or selector. Use page_id for multiple tabs. Args: action (str): Required. Action type. Values: start, stop, open, navigate, navigate_back, snapshot, screenshot, click, type, eval, evaluate, resize, console_messages, network_requests, handle_dialog, file_upload, fill_form, install, press_key, run_code, drag, hover, select_option, tabs, wait_for, pdf, close. url (str): URL to open. Required for action=open or navigate. page_id (str): Page/tab identifier, default "default". Use different page_id for multiple tabs. selector (str): CSS selector to locate element for click/type/hover etc. Prefer ref when available. text (str): Text to type. Required for action=type. code (str): JavaScript code. Required for action=eval, evaluate, or run_code. path (str): File path for screenshot save or PDF export. wait (int): Milliseconds to wait after click. Used with action=click. full_page (bool): Whether to capture full page. Used with action=screenshot. width (int): Viewport width in pixels. Used with action=resize. height (int): Viewport height in pixels. Used with action=resize. level (str): Console log level filter, e.g. "info" or "error". Used with action=console_messages. filename (str): Filename for saving logs or screenshot. Used with console_messages, network_requests, screenshot. accept (bool): Whether to accept dialog (true) or dismiss (false). Used with action=handle_dialog. prompt_text (str): Input for prompt dialog. Used with action=handle_dialog when dialog is prompt. ref (str): Element ref from snapshot output; use for stable targeting. Prefer ref for click/type/hover/screenshot/evaluate/select_option. element (str): Element description for evaluate etc. Prefer ref when available. paths_json (str): JSON array string of file paths. Used with action=file_upload. fields_json (str): JSON object string of form field name to value. Used with action=fill_form. key (str): Key name, e.g. "Enter", "Control+a". Required for action=press_key. submit (bool): Whether to submit (press Enter) after typing. Used with action=type. slowly (bool): Whether to type character by character. Used with action=type. include_static (bool): Whether to include static resource requests. Used with action=network_requests. screenshot_type (str): Screenshot format, "png" or "jpeg". Used with action=screenshot. snapshot_filename (str): File path to save snapshot output. Used with action=snapshot. double_click (bool): Whether to double-click. Used with action=click. button (str): Mouse button: "left", "right", or "middle". Used with action=click. modifiers_json (str): JSON array of modifier keys, e.g. ["Shift","Control"]. Used with action=click. start_ref (str): Drag start element ref. Used with action=drag. end_ref (str): Drag end element ref. Used with action=drag. start_selector (str): Drag start CSS selector. Used with action=drag. end_selector (str): Drag end CSS selector. Used with action=drag. start_element (str): Drag start element description. Used with action=drag. end_element (str): Drag end element description. Used with action=drag. values_json (str): JSON of option value(s) for select. Used with action=select_option. tab_action (str): Tab action: list, new, close, or select. Required for action=tabs. index (int): Tab index for tabs select, zero-based. Used with action=tabs. wait_time (float): Seconds to wait. Used with action=wait_for. text_gone (str): Wait until this text disappears from page. Used with action=wait_for. frame_selector (str): iframe selector, e.g. "iframe#main". Set when operating inside that iframe in snapshot/click/type etc. headed (bool): When True with action=start, launch a visible browser window (non-headless). User can see the real browser. Default False. """ action = (action or "").strip().lower() if not action: return _tool_response( json.dumps( {"ok": False, "error": "action required"}, ensure_ascii=False, indent=2, ), ) page_id = (page_id or "default").strip() or "default" current = _state.get("current_page_id") pages = _state.get("pages") or {} if page_id == "default" and current and current in pages: page_id = current try: if action == "start": return await _action_start(headed=headed) if action == "stop": return await _action_stop() if action == "open": return await _action_open(url, page_id) if action == "navigate": return await _action_navigate(url, page_id) if action == "navigate_back": return await _action_navigate_back(page_id) if action in ("screenshot", "take_screenshot"): return await _action_screenshot( page_id, path or filename, full_page, screenshot_type, ref, element, frame_selector, ) if action == "snapshot": return await _action_snapshot( page_id, snapshot_filename or filename, frame_selector, ) if action == "click": return await _action_click( page_id, selector, ref, element, wait, double_click, button, modifiers_json, frame_selector, ) if action == "type": return await _action_type( page_id, selector, ref, element, text, submit, slowly, frame_selector, ) if action == "eval": return await _action_eval(page_id, code) if action == "evaluate": return await _action_evaluate( page_id, code, ref, element, frame_selector, ) if action == "resize": return await _action_resize(page_id, width, height) if action == "console_messages": return await _action_console_messages( page_id, level, filename or path, ) if action == "handle_dialog": return await _action_handle_dialog(page_id, accept, prompt_text) if action == "file_upload": return await _action_file_upload(page_id, paths_json) if action == "fill_form": return await _action_fill_form(page_id, fields_json) if action == "install": return await _action_install() if action == "press_key": return await _action_press_key(page_id, key) if action == "network_requests": return await _action_network_requests( page_id, include_static, filename or path, ) if action == "run_code": return await _action_run_code(page_id, code) if action == "drag": return await _action_drag( page_id, start_ref, end_ref, start_selector, end_selector, start_element, end_element, frame_selector, ) if action == "hover": return await _action_hover( page_id, ref, element, selector, frame_selector, ) if action == "select_option": return await _action_select_option( page_id, ref, element, values_json, frame_selector, ) if action == "tabs": return await _action_tabs(page_id, tab_action, index) if action == "wait_for": return await _action_wait_for(page_id, wait_time, text, text_gone) if action == "pdf": return await _action_pdf(page_id, path) if action == "close": return await _action_close(page_id) return _tool_response( json.dumps( {"ok": False, "error": f"Unknown action: {action}"}, ensure_ascii=False, indent=2, ), ) except Exception as e: logger.exception("Browser tool error: %s", e, exc_info=True) return _tool_response( json.dumps( {"ok": False, "error": str(e)}, ensure_ascii=False, indent=2, ), ) def _get_page(page_id: str): """Return page for page_id or None if not found.""" return _state["pages"].get(page_id) def _get_refs(page_id: str) -> dict[str, dict]: """Return refs map for page_id (ref -> {role, name?, nth?}).""" return _state["refs"].setdefault(page_id, {}) def _get_root(page, _page_id: str, frame_selector: str = ""): """Return page or frame for frame_selector (ref/selector).""" if not (frame_selector and frame_selector.strip()): return page return page.frame_locator(frame_selector.strip()) def _get_locator_by_ref( page, page_id: str, ref: str, frame_selector: str = "", ): """Resolve snapshot ref to locator; frame_selector for iframe.""" refs = _get_refs(page_id) info = refs.get(ref) if not info: return None role = info.get("role", "generic") name = info.get("name") nth = info.get("nth", 0) root = _get_root(page, page_id, frame_selector) locator = root.get_by_role(role, name=name or None) if nth is not None and nth > 0: locator = locator.nth(nth) return locator def _attach_page_listeners(page, page_id: str) -> None: """Attach console and request listeners for a page.""" logs = _state["console_logs"].setdefault(page_id, []) def on_console(msg): logs.append({"level": msg.type, "text": msg.text}) page.on("console", on_console) requests_list = _state["network_requests"].setdefault(page_id, []) def on_request(req): requests_list.append( { "url": req.url, "method": req.method, "resourceType": getattr(req, "resource_type", None), }, ) def on_response(res): for r in requests_list: if r.get("url") == res.url and "status" not in r: r["status"] = res.status break page.on("request", on_request) page.on("response", on_response) dialogs = _state["pending_dialogs"].setdefault(page_id, []) def on_dialog(dialog): dialogs.append(dialog) page.on("dialog", on_dialog) choosers = _state["pending_file_choosers"].setdefault(page_id, []) def on_filechooser(chooser): choosers.append(chooser) page.on("filechooser", on_filechooser) def _next_page_id() -> str: """Return a unique page_id (page_N). Uses monotonic counter so IDs are not reused after close.""" _state["page_counter"] = _state.get("page_counter", 0) + 1 return f"page_{_state['page_counter']}" def _attach_context_listeners(context) -> None: """When the page opens a new tab (e.g. target=_blank, window.open), register it and set as current.""" def on_page(page): new_id = _next_page_id() _state["refs"][new_id] = {} _state["console_logs"][new_id] = [] _state["network_requests"][new_id] = [] _state["pending_dialogs"][new_id] = [] _state["pending_file_choosers"][new_id] = [] _attach_page_listeners(page, new_id) _state["pages"][new_id] = page _state["current_page_id"] = new_id logger.debug( "New tab opened by page, registered as page_id=%s", new_id, ) context.on("page", on_page) async def _ensure_browser() -> bool: # pylint: disable=too-many-branches """Start browser if not running. Return True if ready, False on failure.""" # Check browser state based on mode if _USE_SYNC_PLAYWRIGHT: if _state["_sync_browser"] is not None and _state["_sync_context"] is not None: _touch_activity() return True else: if _state["browser"] is not None and _state["context"] is not None: _touch_activity() return True try: if _USE_SYNC_PLAYWRIGHT: # Hybrid mode: use sync Playwright in thread pool loop = asyncio.get_event_loop() pw, browser, context = await loop.run_in_executor( _get_executor(), lambda: _sync_browser_launch(_state["headless"]), ) _state["_sync_playwright"] = pw _state["_sync_browser"] = browser _state["_sync_context"] = context else: # Standard mode: use async Playwright async_playwright = _ensure_playwright_async() pw = await async_playwright().start() # Prefer OS default browser when available (e.g. user's default Chrome/Safari). use_default = not is_running_in_container() and os.environ.get( "COPAW_BROWSER_USE_DEFAULT", "1", ).strip().lower() in ("1", "true", "yes") default_kind, default_path = get_system_default_browser() if use_default else (None, None) exe: Optional[str] = None if default_kind == "chromium" and default_path: exe = default_path elif default_kind != "webkit": exe = _chromium_executable_path() if exe: # System Chrome/Edge/Chromium (default or discovered) launch_kwargs: dict[str, Any] = { "headless": _state["headless"], } extra_args = _chromium_launch_args() if extra_args: launch_kwargs["args"] = extra_args launch_kwargs["executable_path"] = exe pw_browser = await pw.chromium.launch(**launch_kwargs) elif default_kind == "webkit" or sys.platform == "darwin": # macOS: default Safari or no Chromium → use WebKit (Safari) pw_browser = await pw.webkit.launch( headless=_state["headless"], ) else: # Windows/Linux without system Chromium → Playwright's Chromium launch_kwargs = {"headless": _state["headless"]} extra_args = _chromium_launch_args() if extra_args: launch_kwargs["args"] = extra_args pw_browser = await pw.chromium.launch(**launch_kwargs) context = await pw_browser.new_context() _attach_context_listeners(context) _state["playwright"] = pw _state["browser"] = pw_browser _state["context"] = context _state["_last_browser_error"] = None _touch_activity() _start_idle_watchdog() return True except Exception as e: _state["_last_browser_error"] = str(e) return False def _start_idle_watchdog() -> None: """Cancel any existing idle watchdog and start a fresh one.""" old_task = _state.get("_idle_task") if old_task and not old_task.done(): old_task.cancel() _state["_idle_task"] = asyncio.ensure_future(_idle_watchdog()) def _cancel_idle_watchdog() -> None: """Cancel the idle watchdog, if running.""" task = _state.get("_idle_task") if task and not task.done(): task.cancel() _state["_idle_task"] = None # pylint: disable=R0912,R0915 async def _action_start( headed: bool = False, ) -> ToolResponse: # Check browser state based on mode if _USE_SYNC_PLAYWRIGHT: browser_exists = _state["_sync_browser"] is not None current_headless = not _state.get("_sync_headless", True) else: browser_exists = _state["browser"] is not None current_headless = _state["headless"] # If user asks for visible window (headed=True) # but browser is already running headless, restart with headed if browser_exists: if headed and current_headless: _cancel_idle_watchdog() try: await _action_stop() except Exception: pass else: return _tool_response( json.dumps( {"ok": True, "message": "Browser already running"}, ensure_ascii=False, indent=2, ), ) # Default: headless (background). Only headed=True (e.g. browser_visible skill) shows window. _state["headless"] = not headed try: if _USE_SYNC_PLAYWRIGHT: loop = asyncio.get_event_loop() pw, browser, context = await loop.run_in_executor( _get_executor(), lambda: _sync_browser_launch(_state["headless"]), ) _state["_sync_playwright"] = pw _state["_sync_browser"] = browser _state["_sync_context"] = context _state["_sync_headless"] = not headed else: async_playwright = _ensure_playwright_async() pw = await async_playwright().start() use_default = not is_running_in_container() and os.environ.get( "COPAW_BROWSER_USE_DEFAULT", "1", ).strip().lower() in ("1", "true", "yes") default_kind, default_path = get_system_default_browser() if use_default else (None, None) exe: Optional[str] = None if default_kind == "chromium" and default_path: exe = default_path elif default_kind != "webkit": exe = _chromium_executable_path() if exe: launch_kwargs = {"headless": _state["headless"]} extra_args = _chromium_launch_args() if extra_args: launch_kwargs["args"] = extra_args launch_kwargs["executable_path"] = exe pw_browser = await pw.chromium.launch(**launch_kwargs) elif default_kind == "webkit" or sys.platform == "darwin": pw_browser = await pw.webkit.launch( headless=_state["headless"], ) else: launch_kwargs = {"headless": _state["headless"]} extra_args = _chromium_launch_args() if extra_args: launch_kwargs["args"] = extra_args pw_browser = await pw.chromium.launch(**launch_kwargs) context = await pw_browser.new_context() _attach_context_listeners(context) _state["playwright"] = pw _state["browser"] = pw_browser _state["context"] = context _touch_activity() _start_idle_watchdog() msg = "Browser started (visible window)" if not _state["headless"] else "Browser started" return _tool_response( json.dumps( {"ok": True, "message": msg}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Browser start failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_stop() -> ToolResponse: _cancel_idle_watchdog() # Check browser state based on mode if not _is_browser_running(): return _tool_response( json.dumps( {"ok": True, "message": "Browser not running"}, ensure_ascii=False, indent=2, ), ) if _USE_SYNC_PLAYWRIGHT: # Close sync browser in thread pool loop = asyncio.get_event_loop() try: await loop.run_in_executor( _get_executor(), _sync_browser_close, ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Browser stop failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) finally: _reset_browser_state() else: # Standard async mode try: await _state["browser"].close() if _state["playwright"] is not None: await _state["playwright"].stop() except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Browser stop failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) finally: _reset_browser_state() return _tool_response( json.dumps( {"ok": True, "message": "Browser stopped"}, ensure_ascii=False, indent=2, ), ) async def _action_open(url: str, page_id: str) -> ToolResponse: url = (url or "").strip() if not url: return _tool_response( json.dumps( {"ok": False, "error": "url required for open"}, ensure_ascii=False, indent=2, ), ) if not await _ensure_browser(): err = _state.get("_last_browser_error") or "Browser not started" return _tool_response( json.dumps( {"ok": False, "error": err}, ensure_ascii=False, indent=2, ), ) try: if _USE_SYNC_PLAYWRIGHT: # Hybrid mode: create page in thread pool loop = asyncio.get_event_loop() # pylint: disable=unnecessary-lambda page = await loop.run_in_executor( _get_executor(), lambda: _state["_sync_context"].new_page(), ) else: # Standard async mode page = await _state["context"].new_page() _state["refs"][page_id] = {} _state["console_logs"][page_id] = [] _state["network_requests"][page_id] = [] _state["pending_dialogs"][page_id] = [] _state["pending_file_choosers"][page_id] = [] _attach_page_listeners(page, page_id) if _USE_SYNC_PLAYWRIGHT: loop = asyncio.get_event_loop() await loop.run_in_executor( _get_executor(), lambda: page.goto(url), ) else: await page.goto(url) _state["pages"][page_id] = page _state["current_page_id"] = page_id return _tool_response( json.dumps( { "ok": True, "message": f"Opened {url}", "page_id": page_id, "url": url, }, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Open failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_navigate(url: str, page_id: str) -> ToolResponse: url = (url or "").strip() if not url: return _tool_response( json.dumps( {"ok": False, "error": "url required for navigate"}, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if _USE_SYNC_PLAYWRIGHT: loop = asyncio.get_event_loop() await loop.run_in_executor( _get_executor(), lambda: page.goto(url), ) else: await page.goto(url) _state["current_page_id"] = page_id return _tool_response( json.dumps( { "ok": True, "message": f"Navigated to {url}", "url": page.url, }, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Navigate failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_screenshot( page_id: str, path: str, full_page: bool, screenshot_type: str = "png", ref: str = "", element: str = "", # pylint: disable=unused-argument frame_selector: str = "", ) -> ToolResponse: path = (path or "").strip() if not path: ext = "jpeg" if screenshot_type == "jpeg" else "png" path = f"page-{int(time.time())}.{ext}" page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if ref and ref.strip(): locator = _get_locator_by_ref( page, page_id, ref.strip(), frame_selector, ) if locator is None: return _tool_response( json.dumps( {"ok": False, "error": f"Unknown ref: {ref}"}, ensure_ascii=False, indent=2, ), ) if _USE_SYNC_PLAYWRIGHT: await _run_sync( locator.screenshot, path=path, type=screenshot_type if screenshot_type == "jpeg" else "png", ) else: await locator.screenshot( path=path, type=screenshot_type if screenshot_type == "jpeg" else "png", ) else: if frame_selector and frame_selector.strip(): root = _get_root(page, page_id, frame_selector) locator = root.locator("body").first if _USE_SYNC_PLAYWRIGHT: await _run_sync( locator.screenshot, path=path, type=screenshot_type if screenshot_type == "jpeg" else "png", ) else: await locator.screenshot( path=path, type=screenshot_type if screenshot_type == "jpeg" else "png", ) else: if _USE_SYNC_PLAYWRIGHT: await _run_sync( page.screenshot, path=path, full_page=full_page, type=screenshot_type if screenshot_type == "jpeg" else "png", ) else: await page.screenshot( path=path, full_page=full_page, type=screenshot_type if screenshot_type == "jpeg" else "png", ) return _tool_response( json.dumps( { "ok": True, "message": f"Screenshot saved to {path}", "path": path, }, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Screenshot failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_click( # pylint: disable=too-many-branches page_id: str, selector: str, ref: str = "", element: str = "", # pylint: disable=unused-argument wait: int = 0, double_click: bool = False, button: str = "left", modifiers_json: str = "", frame_selector: str = "", ) -> ToolResponse: ref = (ref or "").strip() selector = (selector or "").strip() if not ref and not selector: return _tool_response( json.dumps( {"ok": False, "error": "selector or ref required for click"}, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if wait > 0: await asyncio.sleep(wait / 1000.0) mods = _parse_json_param(modifiers_json, []) if not isinstance(mods, list): mods = [] kwargs = { "button": button if button in ("left", "right", "middle") else "left", } if mods: kwargs["modifiers"] = [m for m in mods if m in ("Alt", "Control", "ControlOrMeta", "Meta", "Shift")] if _USE_SYNC_PLAYWRIGHT: loop = asyncio.get_event_loop() if ref: locator = _get_locator_by_ref( page, page_id, ref, frame_selector, ) if locator is None: return _tool_response( json.dumps( {"ok": False, "error": f"Unknown ref: {ref}"}, ensure_ascii=False, indent=2, ), ) if double_click: await loop.run_in_executor( _get_executor(), lambda: locator.dblclick(**kwargs), ) else: await loop.run_in_executor( _get_executor(), lambda: locator.click(**kwargs), ) else: root = _get_root(page, page_id, frame_selector) locator = root.locator(selector).first if double_click: await loop.run_in_executor( _get_executor(), lambda: locator.dblclick(**kwargs), ) else: await loop.run_in_executor( _get_executor(), lambda: locator.click(**kwargs), ) else: # Standard async mode if ref: locator = _get_locator_by_ref( page, page_id, ref, frame_selector, ) if locator is None: return _tool_response( json.dumps( {"ok": False, "error": f"Unknown ref: {ref}"}, ensure_ascii=False, indent=2, ), ) if double_click: await locator.dblclick(**kwargs) else: await locator.click(**kwargs) else: root = _get_root(page, page_id, frame_selector) locator = root.locator(selector).first if double_click: await locator.dblclick(**kwargs) else: await locator.click(**kwargs) return _tool_response( json.dumps( {"ok": True, "message": f"Clicked {ref or selector}"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Click failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_type( page_id: str, selector: str, ref: str = "", element: str = "", # pylint: disable=unused-argument text: str = "", submit: bool = False, slowly: bool = False, frame_selector: str = "", ) -> ToolResponse: ref = (ref or "").strip() selector = (selector or "").strip() if not ref and not selector: return _tool_response( json.dumps( {"ok": False, "error": "selector or ref required for type"}, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if ref: locator = _get_locator_by_ref(page, page_id, ref, frame_selector) if locator is None: return _tool_response( json.dumps( {"ok": False, "error": f"Unknown ref: {ref}"}, ensure_ascii=False, indent=2, ), ) if _USE_SYNC_PLAYWRIGHT: loop = asyncio.get_event_loop() if slowly: await loop.run_in_executor( _get_executor(), lambda: locator.press_sequentially(text or ""), ) else: await loop.run_in_executor( _get_executor(), lambda: locator.fill(text or ""), ) if submit: await loop.run_in_executor( _get_executor(), lambda: locator.press("Enter"), ) else: if slowly: await locator.press_sequentially(text or "") else: await locator.fill(text or "") if submit: await locator.press("Enter") else: root = _get_root(page, page_id, frame_selector) loc = root.locator(selector).first if _USE_SYNC_PLAYWRIGHT: loop = asyncio.get_event_loop() if slowly: await loop.run_in_executor( _get_executor(), lambda: loc.press_sequentially(text or ""), ) else: await loop.run_in_executor( _get_executor(), lambda: loc.fill(text or ""), ) if submit: await loop.run_in_executor( _get_executor(), lambda: loc.press("Enter"), ) else: if slowly: await loc.press_sequentially(text or "") else: await loc.fill(text or "") if submit: await loc.press("Enter") return _tool_response( json.dumps( {"ok": True, "message": f"Typed into {ref or selector}"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Type failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_eval(page_id: str, code: str) -> ToolResponse: code = (code or "").strip() if not code: return _tool_response( json.dumps( {"ok": False, "error": "code required for eval"}, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if code.strip().startswith("(") or code.strip().startswith("function"): if _USE_SYNC_PLAYWRIGHT: result = await _run_sync(page.evaluate, code) else: result = await page.evaluate(code) else: if _USE_SYNC_PLAYWRIGHT: result = await _run_sync( page.evaluate, f"() => {{ return ({code}); }}", ) else: result = await page.evaluate(f"() => {{ return ({code}); }}") try: out = json.dumps( {"ok": True, "result": result}, ensure_ascii=False, indent=2, ) except TypeError: out = json.dumps( {"ok": True, "result": str(result)}, ensure_ascii=False, indent=2, ) return _tool_response(out) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Eval failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_pdf(page_id: str, path: str) -> ToolResponse: path = (path or "page.pdf").strip() or "page.pdf" page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if _USE_SYNC_PLAYWRIGHT: await _run_sync(page.pdf, path=path) else: await page.pdf(path=path) return _tool_response( json.dumps( {"ok": True, "message": f"PDF saved to {path}", "path": path}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"PDF failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_close(page_id: str) -> ToolResponse: page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if _USE_SYNC_PLAYWRIGHT: await _run_sync(page.close) else: await page.close() del _state["pages"][page_id] for key in ( "refs", "refs_frame", "console_logs", "network_requests", "pending_dialogs", "pending_file_choosers", ): _state[key].pop(page_id, None) if _state.get("current_page_id") == page_id: remaining = list(_state["pages"].keys()) _state["current_page_id"] = remaining[0] if remaining else None return _tool_response( json.dumps( {"ok": True, "message": f"Closed page '{page_id}'"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Close failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_snapshot( page_id: str, filename: str, frame_selector: str = "", ) -> ToolResponse: page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if _USE_SYNC_PLAYWRIGHT: # Hybrid mode: execute in thread pool loop = asyncio.get_event_loop() root = _get_root(page, page_id, frame_selector) locator = root.locator(":root") raw = await loop.run_in_executor( _get_executor(), lambda: locator.aria_snapshot(), # pylint: disable=unnecessary-lambda ) else: root = _get_root(page, page_id, frame_selector) locator = root.locator(":root") raw = await locator.aria_snapshot() raw_str = str(raw) if raw is not None else "" snapshot, refs = build_role_snapshot_from_aria( raw_str, interactive=False, compact=False, ) _state["refs"][page_id] = refs _state["refs_frame"][page_id] = frame_selector.strip() if frame_selector else "" out = { "ok": True, "snapshot": snapshot, "refs": list(refs.keys()), "url": page.url, } if frame_selector and frame_selector.strip(): out["frame_selector"] = frame_selector.strip() if filename and filename.strip(): with open(filename.strip(), "w", encoding="utf-8") as f: f.write(snapshot) out["filename"] = filename.strip() return _tool_response(json.dumps(out, ensure_ascii=False, indent=2)) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Snapshot failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_navigate_back(page_id: str) -> ToolResponse: page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if _USE_SYNC_PLAYWRIGHT: await _run_sync(page.go_back) else: await page.go_back() return _tool_response( json.dumps( {"ok": True, "message": "Navigated back", "url": page.url}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Navigate back failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_evaluate( page_id: str, code: str, ref: str = "", element: str = "", # pylint: disable=unused-argument frame_selector: str = "", ) -> ToolResponse: code = (code or "").strip() if not code: return _tool_response( json.dumps( {"ok": False, "error": "code required for evaluate"}, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if ref and ref.strip(): locator = _get_locator_by_ref( page, page_id, ref.strip(), frame_selector, ) if locator is None: return _tool_response( json.dumps( {"ok": False, "error": f"Unknown ref: {ref}"}, ensure_ascii=False, indent=2, ), ) if _USE_SYNC_PLAYWRIGHT: result = await _run_sync(locator.evaluate, code) else: result = await locator.evaluate(code) else: if code.strip().startswith("(") or code.strip().startswith( "function", ): if _USE_SYNC_PLAYWRIGHT: result = await _run_sync(page.evaluate, code) else: result = await page.evaluate(code) else: if _USE_SYNC_PLAYWRIGHT: result = await _run_sync( page.evaluate, f"() => {{ return ({code}); }}", ) else: result = await page.evaluate( f"() => {{ return ({code}); }}", ) try: out = json.dumps( {"ok": True, "result": result}, ensure_ascii=False, indent=2, ) except TypeError: out = json.dumps( {"ok": True, "result": str(result)}, ensure_ascii=False, indent=2, ) return _tool_response(out) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Evaluate failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_resize( page_id: str, width: int, height: int, ) -> ToolResponse: if width <= 0 or height <= 0: return _tool_response( json.dumps( {"ok": False, "error": "width and height must be positive"}, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if _USE_SYNC_PLAYWRIGHT: await _run_sync( page.set_viewport_size, {"width": width, "height": height}, ) else: await page.set_viewport_size({"width": width, "height": height}) return _tool_response( json.dumps( {"ok": True, "message": f"Resized to {width}x{height}"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Resize failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_console_messages( page_id: str, level: str, filename: str, ) -> ToolResponse: level = (level or "info").strip().lower() order = ("error", "warning", "info", "debug") idx = order.index(level) if level in order else 2 page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) logs = _state["console_logs"].get(page_id, []) filtered = [m for m in logs if order.index(m["level"]) <= idx] if level in order else logs lines = [f"[{m['level']}] {m['text']}" for m in filtered] text = "\n".join(lines) if filename and filename.strip(): with open(filename.strip(), "w", encoding="utf-8") as f: f.write(text) return _tool_response( json.dumps( { "ok": True, "message": f"Console messages saved to {filename}", "filename": filename.strip(), }, ensure_ascii=False, indent=2, ), ) return _tool_response( json.dumps( {"ok": True, "messages": filtered, "text": text}, ensure_ascii=False, indent=2, ), ) async def _action_handle_dialog( page_id: str, accept: bool, prompt_text: str, ) -> ToolResponse: page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) dialogs = _state["pending_dialogs"].get(page_id, []) if not dialogs: return _tool_response( json.dumps( {"ok": False, "error": "No pending dialog"}, ensure_ascii=False, indent=2, ), ) try: dialog = dialogs.pop(0) if accept: if prompt_text and hasattr(dialog, "accept"): if _USE_SYNC_PLAYWRIGHT: await _run_sync(dialog.accept, prompt_text) else: await dialog.accept(prompt_text) else: if _USE_SYNC_PLAYWRIGHT: await _run_sync(dialog.accept) else: await dialog.accept() else: if _USE_SYNC_PLAYWRIGHT: await _run_sync(dialog.dismiss) else: await dialog.dismiss() return _tool_response( json.dumps( {"ok": True, "message": "Dialog handled"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Handle dialog failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_file_upload(page_id: str, paths_json: str) -> ToolResponse: page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) paths = _parse_json_param(paths_json, []) if not isinstance(paths, list): paths = [] try: choosers = _state["pending_file_choosers"].get(page_id, []) if not choosers: return _tool_response( json.dumps( { "ok": False, "error": "No chooser. Click upload then file_upload.", }, ensure_ascii=False, indent=2, ), ) chooser = choosers.pop(0) if paths: if _USE_SYNC_PLAYWRIGHT: await _run_sync(chooser.set_files, paths) else: await chooser.set_files(paths) return _tool_response( json.dumps( {"ok": True, "message": f"Uploaded {len(paths)} file(s)"}, ensure_ascii=False, indent=2, ), ) if _USE_SYNC_PLAYWRIGHT: await _run_sync(chooser.set_files, []) else: await chooser.set_files([]) return _tool_response( json.dumps( {"ok": True, "message": "File chooser cancelled"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"File upload failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_fill_form(page_id: str, fields_json: str) -> ToolResponse: page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) fields = _parse_json_param(fields_json, []) if not isinstance(fields, list) or not fields: return _tool_response( json.dumps( {"ok": False, "error": "fields required (JSON array)"}, ensure_ascii=False, indent=2, ), ) refs = _get_refs(page_id) # Use last snapshot's frame so fill_form works after iframe snapshot frame = _state["refs_frame"].get(page_id, "") try: for f in fields: ref = (f.get("ref") or "").strip() if not ref or ref not in refs: continue locator = _get_locator_by_ref(page, page_id, ref, frame) if locator is None: continue field_type = (f.get("type") or "textbox").lower() value = f.get("value") if field_type == "checkbox": if isinstance(value, str): value = value.strip().lower() in ("true", "1", "yes") if _USE_SYNC_PLAYWRIGHT: await _run_sync(locator.set_checked, bool(value)) else: await locator.set_checked(bool(value)) elif field_type == "radio": if _USE_SYNC_PLAYWRIGHT: await _run_sync(locator.set_checked, True) else: await locator.set_checked(True) elif field_type == "combobox": if _USE_SYNC_PLAYWRIGHT: await _run_sync( locator.select_option, label=value if isinstance(value, str) else None, value=value, ) else: await locator.select_option( label=value if isinstance(value, str) else None, value=value, ) elif field_type == "slider": if _USE_SYNC_PLAYWRIGHT: await _run_sync(locator.fill, str(value)) else: await locator.fill(str(value)) else: if _USE_SYNC_PLAYWRIGHT: await _run_sync( locator.fill, str(value) if value is not None else "", ) else: await locator.fill(str(value) if value is not None else "") return _tool_response( json.dumps( {"ok": True, "message": f"Filled {len(fields)} field(s)"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Fill form failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) def _run_playwright_install() -> None: """Run playwright install in a blocking way (for use in thread).""" subprocess.run( [sys.executable, "-m", "playwright", "install"], check=True, capture_output=True, text=True, timeout=600, # 10 minutes max ) async def _action_install() -> ToolResponse: """Install Playwright browsers. If a system Chrome/Chromium/Edge is found, use it and skip download. On macOS with no Chromium, use Safari (WebKit) so no download is needed. Only run playwright install when necessary. """ exe = _chromium_executable_path() if exe: return _tool_response( json.dumps( { "ok": True, "message": f"Using system browser (no download): {exe}", }, ensure_ascii=False, indent=2, ), ) if _use_webkit_fallback(): return _tool_response( json.dumps( { "ok": True, "message": "On macOS using Safari (WebKit); no browser download needed.", }, ensure_ascii=False, indent=2, ), ) try: await asyncio.to_thread(_run_playwright_install) return _tool_response( json.dumps( {"ok": True, "message": "Browser installed"}, ensure_ascii=False, indent=2, ), ) except subprocess.TimeoutExpired: return _tool_response( json.dumps( { "ok": False, "error": "Browser install timed out (10 min). Run manually in terminal: " f"{sys.executable!s} -m playwright install", }, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( { "ok": False, "error": f"Install failed: {e!s}. Install manually: " f"{sys.executable!s} -m pip install playwright && " f"{sys.executable!s} -m playwright install", }, ensure_ascii=False, indent=2, ), ) async def _action_press_key(page_id: str, key: str) -> ToolResponse: key = (key or "").strip() if not key: return _tool_response( json.dumps( {"ok": False, "error": "key required for press_key"}, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if _USE_SYNC_PLAYWRIGHT: await _run_sync(page.keyboard.press, key) else: await page.keyboard.press(key) return _tool_response( json.dumps( {"ok": True, "message": f"Pressed key {key}"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Press key failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_network_requests( page_id: str, include_static: bool, filename: str, ) -> ToolResponse: page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) requests = _state["network_requests"].get(page_id, []) if not include_static: static = ("image", "stylesheet", "font", "media") requests = [r for r in requests if r.get("resourceType") not in static] lines = [f"{r.get('method', '')} {r.get('url', '')} {r.get('status', '')}" for r in requests] text = "\n".join(lines) if filename and filename.strip(): with open(filename.strip(), "w", encoding="utf-8") as f: f.write(text) return _tool_response( json.dumps( { "ok": True, "message": f"Network requests saved to {filename}", "filename": filename.strip(), }, ensure_ascii=False, indent=2, ), ) return _tool_response( json.dumps( {"ok": True, "requests": requests, "text": text}, ensure_ascii=False, indent=2, ), ) async def _action_run_code(page_id: str, code: str) -> ToolResponse: """Run JS in page (like eval). Use evaluate for element (ref).""" code = (code or "").strip() if not code: return _tool_response( json.dumps( {"ok": False, "error": "code required for run_code"}, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if code.strip().startswith("(") or code.strip().startswith("function"): if _USE_SYNC_PLAYWRIGHT: result = await _run_sync(page.evaluate, code) else: result = await page.evaluate(code) else: if _USE_SYNC_PLAYWRIGHT: result = await _run_sync( page.evaluate, f"() => {{ return ({code}); }}", ) else: result = await page.evaluate(f"() => {{ return ({code}); }}") try: out = json.dumps( {"ok": True, "result": result}, ensure_ascii=False, indent=2, ) except TypeError: out = json.dumps( {"ok": True, "result": str(result)}, ensure_ascii=False, indent=2, ) return _tool_response(out) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Run code failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_drag( page_id: str, start_ref: str, end_ref: str, start_selector: str = "", end_selector: str = "", start_element: str = "", # pylint: disable=unused-argument end_element: str = "", # pylint: disable=unused-argument frame_selector: str = "", ) -> ToolResponse: start_ref = (start_ref or "").strip() end_ref = (end_ref or "").strip() start_selector = (start_selector or "").strip() end_selector = (end_selector or "").strip() use_refs = bool(start_ref and end_ref) use_selectors = bool(start_selector and end_selector) if not use_refs and not use_selectors: return _tool_response( json.dumps( { "ok": False, "error": ("drag needs (start_ref,end_ref) or (start_sel,end_sel)"), }, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: root = _get_root(page, page_id, frame_selector) if use_refs: start_locator = _get_locator_by_ref( page, page_id, start_ref, frame_selector, ) end_locator = _get_locator_by_ref( page, page_id, end_ref, frame_selector, ) if start_locator is None or end_locator is None: return _tool_response( json.dumps( {"ok": False, "error": "Unknown ref for drag"}, ensure_ascii=False, indent=2, ), ) else: start_locator = root.locator(start_selector).first end_locator = root.locator(end_selector).first if _USE_SYNC_PLAYWRIGHT: await _run_sync(start_locator.drag_to, end_locator) else: await start_locator.drag_to(end_locator) return _tool_response( json.dumps( {"ok": True, "message": "Drag completed"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Drag failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_hover( page_id: str, ref: str = "", element: str = "", # pylint: disable=unused-argument selector: str = "", frame_selector: str = "", ) -> ToolResponse: ref = (ref or "").strip() selector = (selector or "").strip() if not ref and not selector: return _tool_response( json.dumps( {"ok": False, "error": "hover requires ref or selector"}, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if ref: locator = _get_locator_by_ref(page, page_id, ref, frame_selector) if locator is None: return _tool_response( json.dumps( {"ok": False, "error": f"Unknown ref: {ref}"}, ensure_ascii=False, indent=2, ), ) else: root = _get_root(page, page_id, frame_selector) locator = root.locator(selector).first if _USE_SYNC_PLAYWRIGHT: await _run_sync(locator.hover) else: await locator.hover() return _tool_response( json.dumps( {"ok": True, "message": f"Hovered {ref or selector}"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Hover failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_select_option( page_id: str, ref: str = "", element: str = "", # pylint: disable=unused-argument values_json: str = "", frame_selector: str = "", ) -> ToolResponse: ref = (ref or "").strip() values = _parse_json_param(values_json, []) if not isinstance(values, list): values = [values] if values is not None else [] if not ref: return _tool_response( json.dumps( {"ok": False, "error": "ref required for select_option"}, ensure_ascii=False, indent=2, ), ) if not values: return _tool_response( json.dumps( { "ok": False, "error": "values required (JSON array or comma-separated)", }, ensure_ascii=False, indent=2, ), ) page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: locator = _get_locator_by_ref(page, page_id, ref, frame_selector) if locator is None: return _tool_response( json.dumps( {"ok": False, "error": f"Unknown ref: {ref}"}, ensure_ascii=False, indent=2, ), ) if _USE_SYNC_PLAYWRIGHT: await _run_sync(locator.select_option, value=values) else: await locator.select_option(value=values) return _tool_response( json.dumps( {"ok": True, "message": f"Selected {values}"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Select option failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) async def _action_tabs( # pylint: disable=too-many-return-statements page_id: str, tab_action: str, index: int, ) -> ToolResponse: tab_action = (tab_action or "").strip().lower() if not tab_action: return _tool_response( json.dumps( { "ok": False, "error": "tab_action required (list, new, close, select)", }, ensure_ascii=False, indent=2, ), ) pages = _state["pages"] page_ids = list(pages.keys()) if tab_action == "list": return _tool_response( json.dumps( {"ok": True, "tabs": page_ids, "count": len(page_ids)}, ensure_ascii=False, indent=2, ), ) if tab_action == "new": if _USE_SYNC_PLAYWRIGHT: if not _state["_sync_context"]: ok = await _ensure_browser() if not ok: err = _state.get("_last_browser_error") or "Browser not started" return _tool_response( json.dumps( {"ok": False, "error": err}, ensure_ascii=False, indent=2, ), ) else: if not _state["context"]: ok = await _ensure_browser() if not ok: err = _state.get("_last_browser_error") or "Browser not started" return _tool_response( json.dumps( {"ok": False, "error": err}, ensure_ascii=False, indent=2, ), ) try: if _USE_SYNC_PLAYWRIGHT: page = await _run_sync(_state["_sync_context"].new_page) else: page = await _state["context"].new_page() new_id = _next_page_id() _state["refs"][new_id] = {} _state["console_logs"][new_id] = [] _state["network_requests"][new_id] = [] _state["pending_dialogs"][new_id] = [] _attach_page_listeners(page, new_id) _state["pages"][new_id] = page _state["current_page_id"] = new_id return _tool_response( json.dumps( { "ok": True, "page_id": new_id, "tabs": list(_state["pages"].keys()), }, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"New tab failed: {e!s}"}, ensure_ascii=False, indent=2, ), ) if tab_action == "close": target_id = page_ids[index] if 0 <= index < len(page_ids) else page_id return await _action_close(target_id) if tab_action == "select": target_id = page_ids[index] if 0 <= index < len(page_ids) else page_id _state["current_page_id"] = target_id return _tool_response( json.dumps( { "ok": True, "message": f"Use page_id={target_id} for later actions", "page_id": target_id, }, ensure_ascii=False, indent=2, ), ) return _tool_response( json.dumps( {"ok": False, "error": f"Unknown tab_action: {tab_action}"}, ensure_ascii=False, indent=2, ), ) async def _action_wait_for( page_id: str, wait_time: float, text: str, text_gone: str, ) -> ToolResponse: page = _get_page(page_id) if not page: return _tool_response( json.dumps( {"ok": False, "error": f"Page '{page_id}' not found"}, ensure_ascii=False, indent=2, ), ) try: if wait_time and wait_time > 0: await asyncio.sleep(wait_time) text = (text or "").strip() text_gone = (text_gone or "").strip() if text: locator = page.get_by_text(text) if _USE_SYNC_PLAYWRIGHT: await _run_sync( locator.wait_for, state="visible", timeout=30000, ) else: await locator.wait_for( state="visible", timeout=30000, ) if text_gone: locator = page.get_by_text(text_gone) if _USE_SYNC_PLAYWRIGHT: await _run_sync( locator.wait_for, state="hidden", timeout=30000, ) else: await locator.wait_for( state="hidden", timeout=30000, ) return _tool_response( json.dumps( {"ok": True, "message": "Wait completed"}, ensure_ascii=False, indent=2, ), ) except Exception as e: return _tool_response( json.dumps( {"ok": False, "error": f"Wait failed: {e!s}"}, ensure_ascii=False, indent=2, ), )