From cebe721b881f8be555075d46bb2987e9df0a9daa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:52:31 +0000 Subject: [PATCH 1/3] Initial plan From 7a37092206694461e339b40faa7286f2790749f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:08:38 +0000 Subject: [PATCH 2/3] Add browser_control module, tests, docs, and example Agent-Logs-Url: https://github.com/HyperionGray/python-chrome-devtools-protocol/sessions/f5aedfd0-e970-4dc1-bc96-e614a946247f Co-authored-by: P4X-ng <223870169+P4X-ng@users.noreply.github.com> --- cdp/browser_control.py | 722 ++++++++++++++++++++++++++++ docs/browser_control.rst | 199 ++++++++ docs/index.rst | 1 + docs/overview.rst | 44 +- examples/browser_control_example.py | 120 +++++ test/test_browser_control.py | 452 +++++++++++++++++ 6 files changed, 1529 insertions(+), 9 deletions(-) create mode 100644 cdp/browser_control.py create mode 100644 docs/browser_control.rst create mode 100644 examples/browser_control_example.py create mode 100644 test/test_browser_control.py diff --git a/cdp/browser_control.py b/cdp/browser_control.py new file mode 100644 index 0000000..149ebf2 --- /dev/null +++ b/cdp/browser_control.py @@ -0,0 +1,722 @@ +""" +Browser Control Module + +High-level browser automation API built on top of the CDP domain modules and +``CDPConnection``. This module provides Playwright-style helpers for common +browser automation tasks: element selection, clicking, typing, waiting, +navigation, and screenshots. + +All methods in this module are coroutines that require a connected +``CDPConnection`` instance. + +Example:: + + import asyncio + from cdp.connection import CDPConnection + from cdp import browser_control as bc + + async def main(): + async with CDPConnection("ws://localhost:9222/devtools/page/ID") as conn: + await bc.navigate(conn, "https://example.com") + await bc.wait_for_load(conn) + + node = await bc.query_selector(conn, "h1") + text = await bc.get_text(conn, node) + print(text) + + await bc.click(conn, "a") + await bc.type_text(conn, "input[name='q']", "hello world") + data = await bc.screenshot(conn) + with open("page.png", "wb") as f: + f.write(data) + + asyncio.run(main()) +""" + +from __future__ import annotations + +import asyncio +import base64 +import typing + +from cdp import dom, input_, page, runtime +from cdp.connection import CDPConnection + +__all__ = [ + # Navigation + "navigate", + "reload", + "go_back", + "go_forward", + "wait_for_load", + # Element selection + "query_selector", + "query_selector_all", + # Element interaction + "click", + "double_click", + "hover", + "type_text", + "clear_and_type", + "press_key", + "focus", + "select_option", + # Element inspection + "get_text", + "get_attribute", + "get_bounding_box", + "is_visible", + # Screenshots + "screenshot", + "screenshot_element", + # JavaScript + "evaluate", + "evaluate_on_node", + # Waiting + "wait_for_selector", + "wait_for_event", +] + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +async def _get_document_node(conn: CDPConnection) -> dom.NodeId: + """Return the root document NodeId.""" + document = await conn.execute(dom.get_document(depth=0)) + return document.node_id + + +async def _resolve_node_center( + conn: CDPConnection, node_id: dom.NodeId +) -> typing.Tuple[float, float]: + """Return the (x, y) centre of a DOM node's bounding box.""" + box = await conn.execute(dom.get_box_model(node_id=node_id)) + content = box.content # Quad – flat list of 8 floats: x0,y0 x1,y1 x2,y2 x3,y3 + xs = content[0::2] + ys = content[1::2] + cx = sum(xs) / len(xs) + cy = sum(ys) / len(ys) + return cx, cy + + +# --------------------------------------------------------------------------- +# Navigation +# --------------------------------------------------------------------------- + +async def navigate(conn: CDPConnection, url: str, timeout: float = 30.0) -> page.FrameId: + """Navigate the current page to *url*. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param url: Destination URL. + :param timeout: Maximum seconds to wait for the navigation command. + :returns: The :class:`~cdp.page.FrameId` of the navigated frame. + :raises cdp.connection.CDPCommandError: If navigation fails. + """ + result = await conn.execute(page.navigate(url=url), timeout=timeout) + frame_id: page.FrameId = result[0] + return frame_id + + +async def reload(conn: CDPConnection, ignore_cache: bool = False) -> None: + """Reload the current page. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param ignore_cache: When ``True`` bypass the browser cache (hard reload). + """ + await conn.execute(page.reload(ignore_cache=ignore_cache)) + + +async def go_back(conn: CDPConnection) -> bool: + """Navigate back in history. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :returns: ``True`` if a back entry existed, ``False`` otherwise. + """ + index, entries = await conn.execute(page.get_navigation_history()) + if index > 0: + entry = entries[index - 1] + await conn.execute(page.navigate_to_history_entry(entry_id=entry.id_)) + return True + return False + + +async def go_forward(conn: CDPConnection) -> bool: + """Navigate forward in history. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :returns: ``True`` if a forward entry existed, ``False`` otherwise. + """ + index, entries = await conn.execute(page.get_navigation_history()) + if index < len(entries) - 1: + entry = entries[index + 1] + await conn.execute(page.navigate_to_history_entry(entry_id=entry.id_)) + return True + return False + + +async def wait_for_load( + conn: CDPConnection, + timeout: float = 30.0, +) -> None: + """Wait until the page fires a ``Page.loadEventFired`` event. + + You must have called ``await conn.execute(page.enable())`` before using + this helper so that page events are delivered. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param timeout: Maximum seconds to wait. + :raises asyncio.TimeoutError: If the page does not load within *timeout*. + """ + await wait_for_event(conn, page.LoadEventFired, timeout=timeout) + + +# --------------------------------------------------------------------------- +# Element selection +# --------------------------------------------------------------------------- + +async def query_selector( + conn: CDPConnection, + selector: str, + root: typing.Optional[dom.NodeId] = None, +) -> dom.NodeId: + """Return the :class:`~cdp.dom.NodeId` of the first element matching + *selector* within *root* (defaults to the document root). + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param selector: CSS selector string. + :param root: Optional root node; defaults to the document root. + :returns: Matched :class:`~cdp.dom.NodeId`. + :raises ValueError: If no element matches the selector. + """ + if root is None: + root = await _get_document_node(conn) + node_id = await conn.execute(dom.query_selector(node_id=root, selector=selector)) + if node_id == 0: + raise ValueError(f"No element found for selector: {selector!r}") + return node_id + + +async def query_selector_all( + conn: CDPConnection, + selector: str, + root: typing.Optional[dom.NodeId] = None, +) -> typing.List[dom.NodeId]: + """Return all :class:`~cdp.dom.NodeId` values matching *selector*. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param selector: CSS selector string. + :param root: Optional root node; defaults to the document root. + :returns: List of matched :class:`~cdp.dom.NodeId` values (may be empty). + """ + if root is None: + root = await _get_document_node(conn) + return await conn.execute(dom.query_selector_all(node_id=root, selector=selector)) + + +# --------------------------------------------------------------------------- +# Element interaction +# --------------------------------------------------------------------------- + +async def click( + conn: CDPConnection, + selector_or_node: typing.Union[str, dom.NodeId], + button: str = "left", + click_count: int = 1, +) -> None: + """Click the element identified by *selector_or_node*. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param selector_or_node: CSS selector string or a :class:`~cdp.dom.NodeId`. + :param button: Mouse button – ``"left"``, ``"right"``, or ``"middle"``. + :param click_count: Number of clicks (use ``2`` for double-click). + """ + node = ( + await query_selector(conn, selector_or_node) + if isinstance(selector_or_node, str) + else selector_or_node + ) + # Scroll node into view first so coordinates are correct. + await conn.execute(dom.scroll_into_view_if_needed(node_id=node)) + cx, cy = await _resolve_node_center(conn, node) + _btn = input_.MouseButton(button) + for event_type in ("mouseMoved", "mousePressed", "mouseReleased"): + await conn.execute( + input_.dispatch_mouse_event( + type_=event_type, + x=cx, + y=cy, + button=_btn, + click_count=click_count, + ) + ) + + +async def double_click( + conn: CDPConnection, + selector_or_node: typing.Union[str, dom.NodeId], +) -> None: + """Double-click the element identified by *selector_or_node*. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param selector_or_node: CSS selector string or a :class:`~cdp.dom.NodeId`. + """ + await click(conn, selector_or_node, button="left", click_count=2) + + +async def hover( + conn: CDPConnection, + selector_or_node: typing.Union[str, dom.NodeId], +) -> None: + """Move the mouse pointer over the element identified by *selector_or_node*. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param selector_or_node: CSS selector string or a :class:`~cdp.dom.NodeId`. + """ + node = ( + await query_selector(conn, selector_or_node) + if isinstance(selector_or_node, str) + else selector_or_node + ) + await conn.execute(dom.scroll_into_view_if_needed(node_id=node)) + cx, cy = await _resolve_node_center(conn, node) + await conn.execute( + input_.dispatch_mouse_event(type_="mouseMoved", x=cx, y=cy) + ) + + +async def type_text( + conn: CDPConnection, + selector_or_node: typing.Union[str, dom.NodeId], + text: str, + delay: float = 0.0, +) -> None: + """Focus the element and type *text* into it, character by character. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param selector_or_node: CSS selector string or a :class:`~cdp.dom.NodeId`. + :param text: Text to type. + :param delay: Optional delay in seconds between keystrokes. + """ + await focus(conn, selector_or_node) + for char in text: + await conn.execute( + input_.dispatch_key_event(type_="keyDown", text=char, key=char) + ) + await conn.execute( + input_.dispatch_key_event(type_="keyUp", text=char, key=char) + ) + if delay > 0: + await asyncio.sleep(delay) + + +async def clear_and_type( + conn: CDPConnection, + selector_or_node: typing.Union[str, dom.NodeId], + text: str, + delay: float = 0.0, +) -> None: + """Select all existing text in the element, then type *text*. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param selector_or_node: CSS selector string or a :class:`~cdp.dom.NodeId`. + :param text: Replacement text. + :param delay: Optional delay in seconds between keystrokes. + """ + node = ( + await query_selector(conn, selector_or_node) + if isinstance(selector_or_node, str) + else selector_or_node + ) + await focus(conn, node) + # Select all – platform-agnostic via JavaScript. + await conn.execute( + runtime.evaluate(expression="document.execCommand('selectAll', false, null)") + ) + await type_text(conn, node, text, delay=delay) + + +async def press_key( + conn: CDPConnection, + key: str, + modifiers: int = 0, +) -> None: + """Simulate pressing a single keyboard key. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param key: DOM key name, e.g. ``"Enter"``, ``"Tab"``, ``"Escape"``, + ``"ArrowDown"``, or a single character like ``"a"``. + :param modifiers: Bit-field of modifier keys + (Alt=1, Ctrl=2, Meta=4, Shift=8). + """ + for event_type in ("keyDown", "keyUp"): + await conn.execute( + input_.dispatch_key_event( + type_=event_type, + key=key, + modifiers=modifiers, + ) + ) + + +async def focus( + conn: CDPConnection, + selector_or_node: typing.Union[str, dom.NodeId], +) -> None: + """Move keyboard focus to the element identified by *selector_or_node*. + + :param conn: An open :class:`~cdp.connection.CDPConnection`. + :param selector_or_node: CSS selector string or a :class:`~cdp.dom.NodeId`. + """ + node = ( + await query_selector(conn, selector_or_node) + if isinstance(selector_or_node, str) + else selector_or_node + ) + await conn.execute(dom.focus(node_id=node)) + + +async def select_option( + conn: CDPConnection, + selector_or_node: typing.Union[str, dom.NodeId], + value: str, +) -> None: + """Select the ``