Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 168 additions & 158 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</p>

<p align="center">
<a href="https://mops.readthedocs.io"><img alt="Documentation Status" src="https://img.shields.io/readthedocs/mops?logo=readthedocs&labelColor=%232E353B&label=docs"></a>
<a href="https://mops.readthedocs.io"><img alt="Documentation Status" src="https://readthedocs.org/projects/mops/badge/?version=latest"></a>
<a href="https://github.com/CustomEnv/mops/actions/workflows/static_tests.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/CustomEnv/mops/static_tests.yml?branch=master&logo=github&label=Unit%20Tests&labelColor=%232E353B"></a>
<a href="https://github.com/CustomEnv/mops/actions/workflows/playwright_tests.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/CustomEnv/mops/playwright_tests.yml?branch=master&logo=github&label=Playwright%20Tests&labelColor=%232E353B"></a>
</p>
Expand Down
2 changes: 1 addition & 1 deletion mops/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Wrapper of Selenium, Appium and Playwright with a single API."""

__version__ = '3.5.2'
__version__ = '3.5.3'
__project_name__ = 'mops'
28 changes: 25 additions & 3 deletions mops/base/driver_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
from mops.exceptions import DriverWrapperException
from mops.js_scripts import storage_get_items_js, storage_set_item_js
from mops.mixins.internal_mixin import InternalMixin
from mops.mixins.objects.box import Box
from mops.mixins.objects.driver import Driver
from mops.mixins.objects.visual_comaprison_mixin import hide_before_screenshot, reveal_after_screenshot
from mops.playwright.play_driver import PlayDriver
from mops.selenium.driver.mobile_driver import MobileDriver
Expand All @@ -37,6 +35,7 @@

class DriverWrapperSessions:
all_sessions: ClassVar[list[DriverWrapper]] = []
_MIN_SESSIONS_FOR_COMPARISON: ClassVar[int] = 2

@classmethod
def add_session(cls, driver_wrapper: DriverWrapper) -> None:
Expand Down Expand Up @@ -87,6 +86,29 @@ def is_connected(cls) -> bool:
"""
return any(cls.all_sessions)

@classmethod
def has_different_driver_types(cls) -> bool:
"""
Check if the session pool contains different driver types
(e.g., Selenium + Appium, Selenium + Playwright).

:return: :obj:`bool` - :obj:`True` if at least two sessions use different driver types,
otherwise :obj:`False`.
"""
if len(cls.all_sessions) < cls._MIN_SESSIONS_FOR_COMPARISON:
return False

driver_types = set()
for session in cls.all_sessions:
if session.is_playwright:
driver_types.add('playwright')
elif session.is_appium:
driver_types.add('appium')
elif session.is_selenium:
driver_types.add('selenium')

return len(driver_types) > 1


class DriverWrapper(InternalMixin, Logging, DriverWrapperABC):
"""
Expand Down Expand Up @@ -478,7 +500,7 @@ def __init_base_class__(self) -> None:
)
raise DriverWrapperException(msg)

self._set_static(self._base_cls)
self._set_static(self._base_cls, with_shadow=False)
self._base_cls.__init__(self, driver_container=self.__driver_container)

for name, value in self.__dict__.items():
Expand Down
4 changes: 2 additions & 2 deletions mops/base/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def __new__(mcs, name: str, bases: tuple, namespace: dict, **kwargs: Any) -> Ele
@functools.wraps(orig_init)
def wrapped_init(self: Any, *args: Any, **kw: Any) -> None:
orig_init(self, *args, **kw)
if type(self) is cls and getattr(self, '_initialized', False):
if getattr(self, '_initialized', False):
self._modify_sub_elements()

cls.__init__ = wrapped_init
Expand Down Expand Up @@ -181,7 +181,7 @@ def __init_base_class__(self) -> None:
)
raise DriverWrapperException(msg)

self._set_static(self._base_cls)
self._set_static(self._base_cls, with_shadow=True)
self._base_cls.__init__(self)
self._initialized = True

Expand Down
2 changes: 1 addition & 1 deletion mops/base/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def __init_base_class__(self) -> None:
)
raise DriverWrapperException(msg)

self._set_static(self._base_cls)
self._set_static(self._base_cls, with_shadow=True)
self._base_cls.__init__(self)

@cached_property
Expand Down
54 changes: 41 additions & 13 deletions mops/mixins/internal_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
from mops.utils.internal_utils import (
extract_all_named_objects,
extract_named_objects,
is_driver_wrapper,
)

_shadow_classes: dict[tuple[type, str], type] = {}
_class_configured: dict[type, type] = {}


def get_element_info(element: Any, label: str = 'Selector=') -> str:
"""
Expand Down Expand Up @@ -47,6 +49,7 @@ def get_driver_instance(driver_type: type, instance: type) -> bool:

class InternalMixin:
driver: None
driver_wrapper: None

def _driver_is_instance(self, instance: type) -> bool:
"""Check if the current driver is an instance of the given type."""
Expand All @@ -56,33 +59,58 @@ def _safe_setter(self, var: str, value: Any) -> None:
if not hasattr(self, var):
setattr(self, var, value)

def _get_protected_attrs(self: Any, current_obj_cls: type) -> set:
if not is_driver_wrapper(self):
return set(get_all_static_attributes(current_obj_cls))

def _get_protected_attrs(self: Any, current_obj_cls: type) -> frozenset:
if '_framework_attrs' not in current_obj_cls.__dict__:
current_obj_cls._framework_attrs = set(get_all_static_attributes(current_obj_cls))
current_obj_cls._framework_attrs = frozenset(get_all_static_attributes(current_obj_cls))

return current_obj_cls.__dict__['_framework_attrs']

def _set_static(self: Any, cls: type) -> None:
def _set_static(self: Any, cls: type, with_shadow: bool) -> None:
"""
Set static from base cls (Web/Mobile/Play Element/Page etc.)
Set attributes from base cls onto the class. Uses per-driver shadow
classes when multiple driver types are active.

:return: None
"""
current_obj_cls = self.__class__
obj_cls = self.__class__

if current_obj_cls.__dict__.get('_configured') is cls:
if _class_configured.get(obj_cls) is cls:
return

protected = self._get_protected_attrs(current_obj_cls)
protected = self._get_protected_attrs(obj_cls)

if with_shadow and self.driver_wrapper.session.has_different_driver_types():
obj_cls = self._set_shadow_class(protected)

for name, value in get_static_attributes(cls).items():
if name not in protected:
setattr(current_obj_cls, name, value)
setattr(obj_cls, name, value)

_class_configured[obj_cls] = cls

current_obj_cls._configured = cls
def _set_shadow_class(self, protected: frozenset) -> type:
"""
Create or reuse a per-driver shadow subclass. The given *protected*
set was computed from the original class before any attributes were
injected, so the shadow class starts with the same baseline.

:param protected: pre-computed _framework_attrs of the original class.
:return: the shadow class (a subtype of the original class).
"""
original_cls = self.__class__
key = (original_cls, self.driver_wrapper._base_cls.__name__)
obj_cls = _shadow_classes.get(key)
if not obj_cls:
obj_cls = type(
original_cls.__name__,
(original_cls,),
{'_shadow_class': True, '_framework_attrs': protected},
)
_shadow_classes[key] = obj_cls

self.__class__ = obj_cls

return obj_cls

def _repr_builder(self: Any) -> str | None:
class_name = self.__class__.__name__
Expand Down
6 changes: 4 additions & 2 deletions tests/static_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from selenium.webdriver.remote.webdriver import WebDriver as SeleniumDriver

from mops.base.driver_wrapper import DriverWrapper, DriverWrapperSessions
from mops.mixins.internal_mixin import _shadow_classes, _class_configured
from mops.playwright.play_driver import PlayDriver
from mops.selenium.core.core_driver import CoreDriver

Expand Down Expand Up @@ -163,8 +164,9 @@ def base_teardown():
except AttributeError:
pass
del MockedDriverWrapper._framework_attrs
if '_configured' in MockedDriverWrapper.__dict__:
del MockedDriverWrapper._configured
# Clean up caches to prevent cross-test leakage
_shadow_classes.clear()
_class_configured.clear()


mobile_drivers = [mocked_ios_driver.__name__, mocked_android_driver.__name__]
Expand Down
126 changes: 126 additions & 0 deletions tests/static_tests/integration/test_element_attrs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Test that each driver type gets its own Element subclass in multi-driver mode."""
import inspect

from mops.base.element import Element
from mops.selenium.elements.mobile_element import MobileElement
from mops.selenium.elements.web_element import WebElement
from mops.mixins.objects.driver import Driver
from mock.mock import MagicMock
from appium.webdriver.webdriver import WebDriver as AppiumDriver
from selenium.webdriver.remote.webdriver import WebDriver as SeleniumDriver
from mops.base.driver_wrapper import DriverWrapper, DriverWrapperSessions


def _assert_method_from(element, name, source_cls):
raw = inspect.getattr_static(element, name, None)
assert raw is not None, f"'{name}' not found"
func = raw.__func__ if isinstance(raw, (classmethod, staticmethod)) else raw
qualname = getattr(func, '__qualname__', '')
assert source_cls.__name__ in qualname, (
f"'{name}' expected from {source_cls.__name__}, got {qualname!r}"
)


class MockedDW(DriverWrapper):
pass


def test_elements_get_own_driver_methods():
"""Elements from the last driver type set the class-level methods."""
DriverWrapperSessions.all_sessions = []

# Mobile driver + element
appium_driver_cls = AppiumDriver
appium_driver_cls.__init__ = lambda *a, **kw: None
appium_driver_cls.session_id = None
appium_driver_cls.command_executor = MagicMock()
appium_driver_cls.error_handler = MagicMock()
appium_driver_cls.capabilities = MagicMock(return_value={
'platformName': 'ios', 'browserName': 'safari', 'automationName': 'safari'
})()
mobile_dw = MockedDW(Driver(driver=appium_driver_cls()))

el_mobile = Element('m1', driver_wrapper=mobile_dw)
_assert_method_from(el_mobile, 'click_into_center', MobileElement)

# Web driver + element
selenium_driver_cls = SeleniumDriver
selenium_driver_cls.__init__ = lambda *a, **kw: None
selenium_driver_cls.session_id = None
selenium_driver_cls.command_executor = MagicMock()
selenium_driver_cls.error_handler = MagicMock()
selenium_driver_cls.caps = {}
web_dw = MockedDW(Driver(driver=selenium_driver_cls()))

el_web = Element('w1', driver_wrapper=web_dw)

# Each driver type gets its own cached shadow class
_assert_method_from(el_web, 'click_into_center', WebElement)

# Different driver types get different shadow classes when
# `has_different_driver_types` is active (multi-driver scenario)
assert type(el_mobile) is not type(el_web), 'Different drivers get different shadow classes'

DriverWrapperSessions.all_sessions = []


def test_same_driver_elements_share_class():
"""Elements from the same driver share the same subclass."""
DriverWrapperSessions.all_sessions = []

appium_driver_cls = AppiumDriver
appium_driver_cls.__init__ = lambda *a, **kw: None
appium_driver_cls.session_id = None
appium_driver_cls.command_executor = MagicMock()
appium_driver_cls.error_handler = MagicMock()
appium_driver_cls.capabilities = MagicMock(return_value={
'platformName': 'ios', 'browserName': 'safari', 'automationName': 'safari'
})()
mobile_dw = MockedDW(Driver(driver=appium_driver_cls()))

selenium_driver_cls = SeleniumDriver
selenium_driver_cls.__init__ = lambda *a, **kw: None
selenium_driver_cls.session_id = None
selenium_driver_cls.command_executor = MagicMock()
selenium_driver_cls.error_handler = MagicMock()
selenium_driver_cls.caps = {}
MockedDW(Driver(driver=selenium_driver_cls()))

el1 = Element('a', driver_wrapper=mobile_dw)
el2 = Element('b', driver_wrapper=mobile_dw)

assert type(el1) is type(el2), (
f'Elements with same driver should share class: '
f'{type(el1).__name__} vs {type(el2).__name__}'
)

DriverWrapperSessions.all_sessions = []


class CustomElement(Element):
custom_value = "custom_value"

@property
def value(self):
return self.custom_value


def test_user_override_still_protected():
"""User-defined overrides should still be protected."""
DriverWrapperSessions.all_sessions = []

appium_driver_cls = AppiumDriver
appium_driver_cls.__init__ = lambda *a, **kw: None
appium_driver_cls.session_id = None
appium_driver_cls.command_executor = MagicMock()
appium_driver_cls.error_handler = MagicMock()
appium_driver_cls.capabilities = MagicMock(return_value={
'platformName': 'ios', 'browserName': 'safari', 'automationName': 'safari'
})()

dw = MockedDW(Driver(driver=appium_driver_cls()))
el = CustomElement('test', driver_wrapper=dw)

assert el.value == CustomElement.custom_value

DriverWrapperSessions.all_sessions = []
Loading
Loading