diff --git a/CHANGELOG.md b/CHANGELOG.md index 694b420f..a3eb6334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@
+## v3.4.3 + +### Added +- `DriverWrapper.is_cdp` flag to identify CDP-connected driver instances +- `PlayDriver.quit` graceful error handling for CDP contexts; tracing skip when `is_cdp` is set +- `CoreDriver.quit` graceful error handling for externally-managed browsers when `is_cdp` is set +- `PlayDriver.get_inner_window_size` null-safe viewport handling (returns `Size(0, 0)` when viewport is `None`) + +--- + ## v3.4.2 *Release date: 2026-03-28* diff --git a/docs/source/driver_wrapper/index.md b/docs/source/driver_wrapper/index.md index 83e07b4d..6ae5032b 100644 --- a/docs/source/driver_wrapper/index.md +++ b/docs/source/driver_wrapper/index.md @@ -26,7 +26,7 @@ experience for managing driver sessions, performing operations, and handling cro - 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. 3. **Dynamic Status Attributes:** - - `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. + - `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. 4. **Optimal Driver Setup:** - 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. diff --git a/mops/__init__.py b/mops/__init__.py index 6282fef8..dc7ffed4 100644 --- a/mops/__init__.py +++ b/mops/__init__.py @@ -1,2 +1,2 @@ -__version__ = '3.4.2' +__version__ = '3.4.3' __project_name__ = 'mops' diff --git a/mops/abstraction/driver_wrapper_abc.py b/mops/abstraction/driver_wrapper_abc.py index 59d433bd..37485583 100644 --- a/mops/abstraction/driver_wrapper_abc.py +++ b/mops/abstraction/driver_wrapper_abc.py @@ -45,6 +45,8 @@ class DriverWrapperABC(ABC): is_simulator: bool = False is_real_device: bool = False + is_cdp: bool = False + browser_name: Union[str, None] = None @cached_property diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index 829aec50..2e9b0f7d 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -122,6 +122,8 @@ class DriverWrapper(InternalMixin, Logging, DriverWrapperABC): is_simulator: bool = False is_real_device: bool = False + is_cdp: bool = False + browser_name: Union[str, None] = None def __new__(cls, *args, **kwargs): diff --git a/mops/playwright/play_driver.py b/mops/playwright/play_driver.py index 093d799c..629582c4 100644 --- a/mops/playwright/play_driver.py +++ b/mops/playwright/play_driver.py @@ -174,14 +174,25 @@ def quit(self, silent: bool = False, trace_path: str = 'trace.zip'): :return: :obj:`None` """ - if trace_path: + if trace_path and not self.is_cdp: try: self.context.tracing.stop(path=trace_path) except PlaywrightError: pass - self._base_driver.close() - self.context.close() + if self.is_cdp: + try: + self._base_driver.close() + except PlaywrightError: + pass + + try: + self.context.close() + except PlaywrightError: + pass + else: + self._base_driver.close() + self.context.close() def set_cookie(self, cookies: List[dict]) -> PlayDriver: """ @@ -313,7 +324,10 @@ def get_inner_window_size(self) -> Size: :return: The size of the inner window as a :class:`.Size` object. """ - return Size(**self.driver.viewport_size) + viewport = self.driver.viewport_size + if viewport is None: + return Size(width=0, height=0) + return Size(**viewport) def get_window_size(self) -> Size: """ diff --git a/mops/selenium/core/core_driver.py b/mops/selenium/core/core_driver.py index 32a772d8..c969ee1a 100644 --- a/mops/selenium/core/core_driver.py +++ b/mops/selenium/core/core_driver.py @@ -223,7 +223,13 @@ def quit(self, silent: bool = False, trace_path: str = 'trace.zip'): :return: :obj:`None` """ - self.driver.quit() + if self.is_cdp: + try: + self.driver.quit() + except SeleniumWebDriverException: + pass + else: + self.driver.quit() def set_cookie(self, cookies: List[dict]) -> CoreDriver: """ diff --git a/tests/static_tests/unit/test_cdp_robustness.py b/tests/static_tests/unit/test_cdp_robustness.py new file mode 100644 index 00000000..98457ccf --- /dev/null +++ b/tests/static_tests/unit/test_cdp_robustness.py @@ -0,0 +1,136 @@ +import pytest + +from mock.mock import MagicMock, PropertyMock, patch + +from playwright.sync_api import Page as PlaywrightSourcePage, Browser +from playwright._impl._errors import Error as PlaywrightError + +from selenium.common.exceptions import WebDriverException as SeleniumWebDriverException +from selenium.webdriver.remote.webdriver import WebDriver as SeleniumDriver + +from mops.base.driver_wrapper import DriverWrapper, DriverWrapperSessions +from mops.mixins.objects.driver import Driver + + +@pytest.fixture(autouse=True) +def cleanup_sessions(): + yield + DriverWrapperSessions.all_sessions = [] + + +def _make_playwright_wrapper(is_cdp=False): + mock_page = PlaywrightSourcePage(MagicMock()) + mock_context = MagicMock() + mock_browser = Browser(MagicMock()) + + wrapper = DriverWrapper(Driver(driver=mock_page, context=mock_context, instance=mock_browser)) + wrapper.is_cdp = is_cdp + return wrapper, mock_page, mock_context + + +def _make_selenium_wrapper(is_cdp=False): + selenium_driver = SeleniumDriver + selenium_driver.__init__ = lambda *args, **kwargs: None + selenium_driver.session_id = None + selenium_driver.command_executor = MagicMock() + selenium_driver.error_handler = MagicMock() + selenium_driver.caps = {} + + instance = selenium_driver() + wrapper = DriverWrapper(Driver(driver=instance)) + wrapper.is_cdp = is_cdp + return wrapper, instance + + +class TestIsCdpFlag: + + def test_is_cdp_defaults_to_false_playwright(self): + wrapper, _, _ = _make_playwright_wrapper() + assert wrapper.is_cdp is False + + def test_is_cdp_defaults_to_false_selenium(self): + wrapper, _ = _make_selenium_wrapper() + assert wrapper.is_cdp is False + + def test_is_cdp_can_be_set_true_playwright(self): + wrapper, _, _ = _make_playwright_wrapper(is_cdp=True) + assert wrapper.is_cdp is True + + def test_is_cdp_can_be_set_true_selenium(self): + wrapper, _ = _make_selenium_wrapper(is_cdp=True) + assert wrapper.is_cdp is True + + +class TestPlayDriverCdpQuit: + + def test_cdp_quit_suppresses_page_close_error(self): + wrapper, mock_page, mock_context = _make_playwright_wrapper(is_cdp=True) + mock_page.close = MagicMock(side_effect=PlaywrightError('Target page closed')) + mock_context.close = MagicMock() + + wrapper.quit(silent=True) + mock_page.close.assert_called_once() + + def test_cdp_quit_suppresses_context_close_error(self): + wrapper, mock_page, mock_context = _make_playwright_wrapper(is_cdp=True) + mock_page.close = MagicMock() + mock_context.close = MagicMock(side_effect=PlaywrightError('Context closed')) + + wrapper.quit(silent=True) + mock_context.close.assert_called_once() + + def test_cdp_quit_skips_tracing(self): + wrapper, _, mock_context = _make_playwright_wrapper(is_cdp=True) + wrapper.quit(silent=True, trace_path='trace.zip') + mock_context.tracing.stop.assert_not_called() + + def test_non_cdp_quit_calls_tracing(self): + wrapper, mock_page, mock_context = _make_playwright_wrapper(is_cdp=False) + mock_page.close = MagicMock() + mock_context.close = MagicMock() + wrapper.quit(silent=True, trace_path='trace.zip') + mock_context.tracing.stop.assert_called_once_with(path='trace.zip') + + def test_non_cdp_quit_propagates_close_error(self): + wrapper, mock_page, mock_context = _make_playwright_wrapper(is_cdp=False) + mock_page.close = MagicMock(side_effect=PlaywrightError('Unexpected error')) + mock_context.tracing.stop = MagicMock() + + with pytest.raises(PlaywrightError): + wrapper.quit(silent=True) + + +class TestCoreDriverCdpQuit: + + def test_cdp_quit_suppresses_webdriver_error(self): + wrapper, driver_instance = _make_selenium_wrapper(is_cdp=True) + driver_instance.quit = MagicMock(side_effect=SeleniumWebDriverException('Browser already closed')) + + wrapper.quit(silent=True) + driver_instance.quit.assert_called_once() + + def test_non_cdp_quit_propagates_webdriver_error(self): + wrapper, driver_instance = _make_selenium_wrapper(is_cdp=False) + driver_instance.quit = MagicMock(side_effect=SeleniumWebDriverException('Unexpected error')) + + with pytest.raises(SeleniumWebDriverException): + wrapper.quit(silent=True) + + +class TestPlayDriverViewportNullSafe: + + def test_get_inner_window_size_returns_zero_when_viewport_none(self): + wrapper, mock_page, _ = _make_playwright_wrapper() + type(mock_page).viewport_size = PropertyMock(return_value=None) + + size = wrapper.get_inner_window_size() + assert size.width == 0 + assert size.height == 0 + + def test_get_inner_window_size_returns_values_when_viewport_set(self): + wrapper, mock_page, _ = _make_playwright_wrapper() + type(mock_page).viewport_size = PropertyMock(return_value={'width': 1920, 'height': 1080}) + + size = wrapper.get_inner_window_size() + assert size.width == 1920 + assert size.height == 1080