Skip to content

Commit d4af690

Browse files
committed
Add DriverWrapper.connect_cdp for CDP browser connections
Problem: MOPS requires users to manually create driver objects when connecting to remote browsers via Chrome DevTools Protocol (CDP). This involves boilerplate code for lifecycle management and cleanup. Use cases: - Testing Electron apps on remote machines via CDP + SSH tunnel - Connecting to cloud browser services (BrowserStack, Sauce Labs) - Attaching to an already-running browser instance for debugging - Testing CEF-based or WebView2 applications Solution: Add a factory classmethod DriverWrapper.connect_cdp(endpoint_url) that handles the full lifecycle. Supports both Playwright and Selenium engines via the engine parameter (default: "playwright"). Playwright engine: Starts Playwright, connects via chromium.connect_over_cdp, wraps the resulting page. Playwright instance is stopped on quit(). Selenium engine: Creates Chrome WebDriver with debugger_address option pointing to the CDP endpoint. Architectural changes: - DriverWrapper.is_cdp flag: identifies CDP-connected wrappers, used by PlayDriver.quit() to skip tracing on pre-existing CDP contexts - PlayDriver.quit(): tracing.stop() skipped for CDP (not just caught); page.close() and context.close() wrapped in try/except - CoreDriver.quit(): wrapped in try/except for externally-managed browsers - PlayDriver.get_inner_window_size(): null-safe for CDP default viewport API: - DriverWrapper.connect_cdp(endpoint_url, engine, timeout, page_index, viewport_size) — main entry point - DriverWrapperABC.connect_cdp() — abstract interface - DriverWrapper.is_cdp — bool flag (False by default, True after connect_cdp) Version bump: 3.3.1 -> 3.4.0 Documentation: - Getting Started: CDP section with both engine examples + limitations - Index: CDP listed in key features - DriverWrapper overview: CDP mentioned in description and status attrs - CHANGELOG: v3.4.0 entry Tests: 14 unit tests covering both engines, is_cdp flag, URL parsing, parameters, cleanup, sessions, and compatibility Made-with: Cursor
1 parent eaeffc3 commit d4af690

File tree

10 files changed

+507
-9
lines changed

10 files changed

+507
-9
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
<br>
44

5+
## v3.4.0
6+
7+
### Added
8+
- `DriverWrapper.connect_cdp` class method for connecting to remote browsers via CDP (supports both Playwright and Selenium engines)
9+
- `DriverWrapper.is_cdp` flag to identify CDP-connected driver instances
10+
- `PlayDriver.quit` graceful error handling for CDP and pre-existing contexts; tracing skip for CDP
11+
- `CoreDriver.quit` graceful error handling for externally-managed browsers (CDP)
12+
- `PlayDriver.get_inner_window_size` null-safe viewport handling for CDP connections
13+
14+
---
15+
516
## v3.3.1
617
*Release date: 2026-01-05*
718

docs/source/driver_wrapper/index.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ The `DriverWrapper` module provides a unified interface to interact with differe
1515
such as _Selenium_, _Appium_, and _Playwright_. It abstracts the complexities of these frameworks and offers a seamless
1616
experience for managing driver sessions, performing operations, and handling cross-platform automation tasks.
1717

18+
It also supports connecting to remote browsers via **Chrome DevTools Protocol (CDP)** using the
19+
`DriverWrapper.connect_cdp()` class method, enabling testing of Electron applications, cloud browser
20+
services, and pre-existing browser instances.
21+
1822
<br>
1923

2024
### Core Benefits & Rules
@@ -26,7 +30,7 @@ experience for managing driver sessions, performing operations, and handling cro
2630
- The `DriverWrapper` and its underlying `Driver` instance are easily accessible within your `Page`, `Group`, and `Element` objects, allowing for consistent and efficient interactions across your test suite.
2731

2832
3. **Dynamic Status Attributes:**
29-
- `DriverWrapper` provides various status attributes (e.g., `is_mobile`, `is_selenium`, `is_playwright`) that help you tailor your test behavior based on the current driver environment, ensuring more precise control and adaptability in your tests.
33+
- `DriverWrapper` provides various status attributes (e.g., `is_mobile`, `is_selenium`, `is_playwright`, `is_cdp`) that help you tailor your test behavior based on the current driver environment, ensuring more precise control and adaptability in your tests.
3034

3135
4. **Optimal Driver Setup:**
3236
- The initialization of the source driver should be handled within your testing framework. This approach ensures that the browser or device starts with the most appropriate configuration, leading to more reliable and efficient test executions.

docs/source/getting_started.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,60 @@ def driver_wrapper():
142142

143143
---
144144

145+
<br>
146+
147+
### CDP connection setup
148+
149+
```{note}
150+
CDP (Chrome DevTools Protocol) connection is useful for testing Electron applications,
151+
connecting to cloud browser services, or attaching to an already-running browser instance.
152+
Both Playwright and Selenium engines are supported.
153+
```
154+
155+
**Playwright (default):**
156+
157+
```python
158+
import pytest # noqa
159+
from mops.base.driver_wrapper import DriverWrapper
160+
161+
162+
@pytest.fixture
163+
def driver_wrapper():
164+
wrapper = DriverWrapper.connect_cdp("http://localhost:9222")
165+
yield wrapper
166+
wrapper.quit()
167+
```
168+
169+
**Selenium:**
170+
171+
```python
172+
import pytest # noqa
173+
from mops.base.driver_wrapper import DriverWrapper
174+
175+
176+
@pytest.fixture
177+
def driver_wrapper():
178+
wrapper = DriverWrapper.connect_cdp("http://localhost:9222", engine="selenium")
179+
yield wrapper
180+
wrapper.quit()
181+
```
182+
183+
```{attention}
184+
**Playwright CDP limitations:**
185+
186+
- ``record_har_path`` and ``record_video_dir`` cannot be set on pre-existing contexts.
187+
- Network interception may also be limited.
188+
- ``viewport_size`` is not set by default — pass it explicitly if your tests rely on ``get_inner_window_size()``.
189+
- Tab creation via ``create_new_tab()`` may behave differently with CDP contexts.
190+
191+
**Selenium CDP limitations:**
192+
193+
- The browser is managed externally — ``quit()`` will attempt to close it gracefully,
194+
but the browser process may remain if it was started outside the test.
195+
```
196+
197+
---
198+
145199
## 4. Write A Test
146200

147201
<br>

docs/source/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ process, giving you the flexibility and power to automate complex testing scenar
1717
- **Seamless Integration**: Mops integrates with Selenium, Appium, and Playwright, allowing you to use the best-suited engine for your specific testing needs.
1818
- **Unified API**: A single, easy-to-use API that abstracts away the differences between Selenium, Appium, and Playwright, making your test scripts more readable and maintainable.
1919
- **Engine Switching**: Switch between Selenium, Appium, and Playwright within the same test case, enabling cross-platform and cross-browser testing with minimal effort.
20+
- **CDP Connection**: Connect to remote browsers via Chrome DevTools Protocol using `DriverWrapper.connect_cdp()` — ideal for Electron apps, cloud browser services, and pre-existing browser instances. Both Playwright and Selenium engines are supported.
2021
- **Visual Regression Testing**: Perform visual regression tests using the integrated visual regression tool, available across all supported frameworks. This ensures your UI remains consistent across different browsers and devices.
2122
- **Advanced Features**: Leverage the advanced features of each framework, such as Playwright's mocks and Appium's real mobile devices support, all while using the same testing framework.
2223
- **Extensibility**: Extend the framework with custom functionality tailored to your project's specific requirements.

mops/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = '3.3.1'
1+
__version__ = '3.4.0'
22
__project_name__ = 'mops'

mops/abstraction/driver_wrapper_abc.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from abc import ABC
44
from functools import cached_property
5-
from typing import List, Union, Any, Tuple, TYPE_CHECKING
5+
from typing import List, Union, Any, Tuple, Optional, Dict, TYPE_CHECKING
66

77
from playwright.sync_api import Page as PlaywrightPage
88

@@ -45,6 +45,8 @@ class DriverWrapperABC(ABC):
4545
is_simulator: bool = False
4646
is_real_device: bool = False
4747

48+
is_cdp: bool = False
49+
4850
browser_name: Union[str, None] = None
4951

5052
@cached_property
@@ -95,6 +97,55 @@ def quit(self, silent: bool = False, trace_path: str = 'trace.zip'):
9597
"""
9698
raise NotImplementedError()
9799

100+
@classmethod
101+
def connect_cdp(
102+
cls,
103+
endpoint_url: str,
104+
engine: str = 'playwright',
105+
timeout: int = 30000,
106+
page_index: int = 0,
107+
viewport_size: Optional[Dict[str, int]] = None,
108+
) -> DriverWrapper:
109+
"""
110+
Connect to a remote browser via Chrome DevTools Protocol.
111+
112+
Creates a connection to the specified CDP endpoint and wraps the resulting
113+
driver in a :class:`DriverWrapper`. Useful for testing Electron applications,
114+
connecting to cloud browser services, or attaching to an already-running
115+
browser instance.
116+
117+
**Playwright engine:**
118+
119+
Starts a Playwright instance internally and connects via
120+
``chromium.connect_over_cdp``. The Playwright instance is stopped
121+
automatically when :meth:`quit` is called.
122+
123+
.. note::
124+
Some Playwright features are unavailable in CDP mode:
125+
``record_har_path`` and ``record_video_dir`` cannot be set on pre-existing contexts.
126+
Network interception may also be limited.
127+
128+
**Selenium engine:**
129+
130+
Creates a Chrome WebDriver with ``debugger_address`` option pointing
131+
to the CDP endpoint.
132+
133+
:param endpoint_url: CDP endpoint URL (e.g., ``"http://localhost:9222"``).
134+
:type endpoint_url: str
135+
:param engine: The engine to use for the connection. ``"playwright"`` or ``"selenium"``.
136+
:type engine: str
137+
:param timeout: Connection timeout in milliseconds (Playwright only).
138+
:type timeout: int
139+
:param page_index: Index of the page to use from the connected context
140+
(default: 0, Playwright only).
141+
:type page_index: int
142+
:param viewport_size: Optional viewport size dict ``{"width": int, "height": int}``.
143+
:type viewport_size: typing.Optional[typing.Dict[str, int]]
144+
:return: Initialized :class:`DriverWrapper` connected to the remote browser.
145+
:rtype: DriverWrapper
146+
"""
147+
raise NotImplementedError()
148+
98149
def wait(self, timeout: Union[int, float] = WAIT_UNIT, reason: str = '') -> DriverWrapper:
99150
"""
100151
Pauses the execution for a specified amount of time.

mops/base/driver_wrapper.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Union, Type, List, Tuple, TYPE_CHECKING
3+
from typing import Union, Type, List, Tuple, Optional, Dict, TYPE_CHECKING
44

55
from PIL import Image
66
from appium.webdriver.webdriver import WebDriver as AppiumDriver
@@ -9,6 +9,7 @@
99
Page as PlaywrightDriver,
1010
Browser as PlaywrightBrowser,
1111
BrowserContext as PlaywrightContext,
12+
sync_playwright,
1213
)
1314

1415
from mops.mixins.objects.box import Box
@@ -122,6 +123,8 @@ class DriverWrapper(InternalMixin, Logging, DriverWrapperABC):
122123
is_simulator: bool = False
123124
is_real_device: bool = False
124125

126+
is_cdp: bool = False
127+
125128
browser_name: Union[str, None] = None
126129

127130
def __new__(cls, *args, **kwargs):
@@ -166,6 +169,133 @@ def __init__(self, driver: Driver):
166169
self.is_desktop = False
167170
self.is_mobile = True
168171

172+
@classmethod
173+
def connect_cdp(
174+
cls,
175+
endpoint_url: str,
176+
engine: str = 'playwright',
177+
timeout: int = 30000,
178+
page_index: int = 0,
179+
viewport_size: Optional[Dict[str, int]] = None,
180+
) -> DriverWrapper:
181+
"""
182+
Connect to a remote browser via Chrome DevTools Protocol.
183+
184+
Creates a connection to the specified CDP endpoint and wraps the resulting
185+
driver in a :class:`DriverWrapper`. Useful for testing Electron applications,
186+
connecting to cloud browser services, or attaching to an already-running
187+
browser instance.
188+
189+
**Playwright engine:**
190+
191+
Starts a Playwright instance internally and connects via
192+
``chromium.connect_over_cdp``. The Playwright instance is stopped
193+
automatically when :meth:`quit` is called.
194+
195+
.. note::
196+
Some Playwright features are unavailable in CDP mode:
197+
``record_har_path`` and ``record_video_dir`` cannot be set on pre-existing contexts.
198+
Network interception may also be limited.
199+
200+
**Selenium engine:**
201+
202+
Creates a Chrome WebDriver with ``debugger_address`` option pointing
203+
to the CDP endpoint.
204+
205+
:param endpoint_url: CDP endpoint URL (e.g., ``"http://localhost:9222"``).
206+
:type endpoint_url: str
207+
:param engine: The engine to use for the connection. ``"playwright"`` or ``"selenium"``.
208+
:type engine: str
209+
:param timeout: Connection timeout in milliseconds (Playwright only).
210+
:type timeout: int
211+
:param page_index: Index of the page to use from the connected context
212+
(default: 0, Playwright only).
213+
:type page_index: int
214+
:param viewport_size: Optional viewport size dict ``{"width": int, "height": int}``.
215+
:type viewport_size: typing.Optional[typing.Dict[str, int]]
216+
:return: Initialized :class:`DriverWrapper` connected to the remote browser.
217+
:rtype: DriverWrapper
218+
"""
219+
if engine == 'playwright':
220+
return cls._connect_cdp_playwright(endpoint_url, timeout, page_index, viewport_size)
221+
elif engine == 'selenium':
222+
return cls._connect_cdp_selenium(endpoint_url, viewport_size)
223+
else:
224+
raise DriverWrapperException(f'Unsupported engine "{engine}". Use "playwright" or "selenium".')
225+
226+
@classmethod
227+
def _connect_cdp_playwright(
228+
cls,
229+
endpoint_url: str,
230+
timeout: int,
231+
page_index: int,
232+
viewport_size: Optional[Dict[str, int]],
233+
) -> DriverWrapper:
234+
"""
235+
Create a Playwright CDP connection and wrap it in a :class:`DriverWrapper`.
236+
237+
:param endpoint_url: CDP endpoint URL.
238+
:type endpoint_url: str
239+
:param timeout: Connection timeout in milliseconds.
240+
:type timeout: int
241+
:param page_index: Index of the page to use from the connected context.
242+
:type page_index: int
243+
:param viewport_size: Optional viewport dimensions.
244+
:type viewport_size: typing.Optional[typing.Dict[str, int]]
245+
:return: Initialized :class:`DriverWrapper`.
246+
:rtype: DriverWrapper
247+
"""
248+
pw = sync_playwright().start()
249+
browser = pw.chromium.connect_over_cdp(endpoint_url, timeout=timeout)
250+
context = browser.contexts[0]
251+
page = context.pages[page_index]
252+
253+
if viewport_size:
254+
page.set_viewport_size(viewport_size)
255+
256+
driver = Driver(driver=page, context=context, instance=browser)
257+
wrapper = cls(driver)
258+
wrapper._playwright_instance = pw
259+
wrapper.is_cdp = True
260+
return wrapper
261+
262+
@classmethod
263+
def _connect_cdp_selenium(
264+
cls,
265+
endpoint_url: str,
266+
viewport_size: Optional[Dict[str, int]],
267+
) -> DriverWrapper:
268+
"""
269+
Create a Selenium CDP connection and wrap it in a :class:`DriverWrapper`.
270+
271+
Imports are deferred to avoid requiring Chrome-specific Selenium packages
272+
when only Playwright is used.
273+
274+
:param endpoint_url: CDP endpoint URL.
275+
:type endpoint_url: str
276+
:param viewport_size: Optional viewport dimensions.
277+
:type viewport_size: typing.Optional[typing.Dict[str, int]]
278+
:return: Initialized :class:`DriverWrapper`.
279+
:rtype: DriverWrapper
280+
"""
281+
from selenium.webdriver.chrome.options import Options as ChromeOptions
282+
from selenium.webdriver.chrome.webdriver import WebDriver as ChromeWebDriver
283+
284+
debugger_address = endpoint_url.replace('http://', '').replace('https://', '').rstrip('/')
285+
286+
options = ChromeOptions()
287+
options.debugger_address = debugger_address
288+
289+
selenium_driver = ChromeWebDriver(options=options)
290+
291+
if viewport_size:
292+
selenium_driver.set_window_size(viewport_size['width'], viewport_size['height'])
293+
294+
driver = Driver(driver=selenium_driver)
295+
wrapper = cls(driver)
296+
wrapper.is_cdp = True
297+
return wrapper
298+
169299
def quit(self, silent: bool = False, trace_path: str = 'trace.zip'):
170300
"""
171301
Quit the driver instance.
@@ -191,6 +321,10 @@ def quit(self, silent: bool = False, trace_path: str = 'trace.zip'):
191321
self._base_cls.quit(self, trace_path)
192322
self.session.remove_session(self)
193323

324+
if getattr(self, '_playwright_instance', None):
325+
self._playwright_instance.stop()
326+
self._playwright_instance = None
327+
194328
def save_screenshot(
195329
self,
196330
file_name: str,

mops/playwright/play_driver.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,21 @@ def quit(self, silent: bool = False, trace_path: str = 'trace.zip'):
174174
175175
:return: :obj:`None`
176176
"""
177-
if trace_path:
177+
if trace_path and not self.is_cdp:
178178
try:
179179
self.context.tracing.stop(path=trace_path)
180180
except PlaywrightError:
181181
pass
182182

183-
self._base_driver.close()
184-
self.context.close()
183+
try:
184+
self._base_driver.close()
185+
except PlaywrightError:
186+
pass
187+
188+
try:
189+
self.context.close()
190+
except PlaywrightError:
191+
pass
185192

186193
def set_cookie(self, cookies: List[dict]) -> PlayDriver:
187194
"""
@@ -313,7 +320,10 @@ def get_inner_window_size(self) -> Size:
313320
314321
:return: The size of the inner window as a :class:`.Size` object.
315322
"""
316-
return Size(**self.driver.viewport_size)
323+
viewport = self.driver.viewport_size
324+
if viewport is None:
325+
return Size(width=0, height=0)
326+
return Size(**viewport)
317327

318328
def get_window_size(self) -> Size:
319329
"""

0 commit comments

Comments
 (0)