diff --git a/.github/workflows/static_tests.yml b/.github/workflows/static_tests.yml index 40933975..4464c691 100644 --- a/.github/workflows/static_tests.yml +++ b/.github/workflows/static_tests.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: [ "3.9", "3.11", "3.12", "3.13"] steps: - name: Checkout code diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f599da4..cc705b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@
+## v3.4.0 (Performance improvement) +*Release date: 2026-03-26* + +### Breaking Changes +- **`Group` subclasses**: `parent` is now correctly set on sub-elements defined after `super().__init__()` — +previously such elements did not receive `parent` argument + +### Added +- `Element.sub_elements` dict — collected once and reused instead of rescanning on every access +- `ElementMeta` metaclass — triggers `_modify_sub_elements` automatically after `__init__` of the final class +- `get_static_attributes` / `get_all_static_attributes` with `lru_cache` — replaces repeated attribute scanning +- `get_driver_instance` with `lru_cache` — caches `isinstance` results for driver type checks +- `_driver_is_instance` method on `InternalMixin` — single cached entry point for driver type detection + +### Changed +- `all_tags` converted to `frozenset` for O(1) membership checks +- `initialize_objects` no longer recurses manually — delegates to `_modify_sub_elements` on each child +- `set_parent_for_attr` uses `sub_elements` dict instead of rescanning object attributes +- `get_child_elements_with_names` / `safe_getattribute` removed, replaced by `extract_named_objects` / `extract_all_named_objects` +- `locator`, `locator_type`, `log_locator` on `Element` converted to lazy properties — resolved on first access +- `__copy__` added to `Element` for explicit shallow copy control +- `__getattribute__` override removed from `Element` — initialization guard moved to `CoreElement`/`PlayElement` + +### Fixed +- Error messages for unsupported driver type now include the actual driver class name and list expected types + +--- + ## v3.3.2 *Release date: 2026-03-24* @@ -16,6 +44,8 @@ ### Changed - `safe_call` exceptions list +--- + ## v3.3.0 *Release date: 2026-01-05* diff --git a/mops/__init__.py b/mops/__init__.py index 9553764d..bf3c0c1f 100644 --- a/mops/__init__.py +++ b/mops/__init__.py @@ -1,2 +1,2 @@ -__version__ = '3.3.2' +__version__ = '3.4.0' __project_name__ = 'mops' diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index 8a493918..80c63e21 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -24,10 +24,32 @@ class ElementABC(MixinABC, ABC): - locator: Union[Locator, str] - name: str = '' - parent: Union[Any, bool, None] = None - wait: Optional[bool] = None + name: str + parent: Union[Any, bool, None] + wait: Optional[bool] + + _locator: Union[str, Locator] + _locator_type: Union[str, None] = None + + @property + def locator(self) -> str: + raise NotImplementedError() + + @locator.setter + def locator(self, value: Union[Locator, str]) -> None: + raise NotImplementedError() + + @property + def locator_type(self) -> str: + raise NotImplementedError() + + @locator_type.setter + def locator_type(self, value: str) -> None: + raise NotImplementedError() + + @property + def log_locator(self) -> str: + raise NotImplementedError() @property def element(self) -> Union[SeleniumWebElement, AppiumWebElement, PlayWebElement]: @@ -925,3 +947,11 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Element]: :return: A list of wrapped :class:`Element` objects. """ raise NotImplementedError() + + def _set_locator(self) -> None: + """ + Set locator for current object + + :return: :obj:`None` + """ + raise NotImplementedError() diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index c1e5148c..84a7f535 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -21,7 +21,7 @@ from mops.selenium.driver.web_driver import WebDriver from mops.exceptions import DriverWrapperException from mops.mixins.internal_mixin import InternalMixin -from mops.utils.internal_utils import get_attributes_from_object, get_child_elements_with_names +from mops.utils.internal_utils import extract_named_objects, get_attributes_from_object from mops.utils.logs import Logging, LogLevel @@ -130,7 +130,7 @@ def __new__(cls, *args, **kwargs): else: cls = super().__new__(type(f'ShadowDriverWrapper', (cls, ), get_attributes_from_object(cls))) # noqa - for name, _ in get_child_elements_with_names(cls, bool).items(): + for name, _ in extract_named_objects(cls, bool).items(): setattr(cls, name, False) return cls @@ -348,7 +348,11 @@ def __init_base_class__(self) -> None: self.is_selenium = True self._base_cls = WebDriver else: - raise DriverWrapperException(f'Cant specify {self.__class__.__name__}') + raise DriverWrapperException( + f'Cannot initialize {self.__class__.__name__}: ' + f'unsupported driver type "{type(source_driver).__name__}". ' + f'Expected Playwright, Appium or Selenium driver instance' + ) self._set_static(self._base_cls) self._base_cls.__init__(self, driver_container=self.__driver_container) diff --git a/mops/base/element.py b/mops/base/element.py index 82ddbaa6..9ddd080e 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -1,7 +1,10 @@ from __future__ import annotations +import functools import time +from abc import ABCMeta from copy import copy +from functools import cached_property from typing import Union, List, Type, Tuple, Optional, TYPE_CHECKING from PIL.Image import Image @@ -33,10 +36,8 @@ WAIT_EL, is_target_on_screen, initialize_objects, - get_child_elements_with_names, - safe_getattribute, + extract_named_objects, set_parent_for_attr, - is_page, QUARTER_WAIT_EL, ) from mops.utils.decorators import wait_condition, wait_continuous @@ -45,7 +46,22 @@ from mops.base.group import Group -class Element(DriverMixin, InternalMixin, Logging, ElementABC): +class ElementMeta(ABCMeta): + def __new__(mcs, name, bases, namespace, **kwargs): + cls = super().__new__(mcs, name, bases, namespace, **kwargs) + orig_init = cls.__init__ + + @functools.wraps(orig_init) + def wrapped_init(self, *args, **kw): + orig_init(self, *args, **kw) + if type(self) is cls and getattr(self, '_initialized', False): + self._modify_sub_elements() + + cls.__init__ = wrapped_init + return cls + + +class Element(DriverMixin, InternalMixin, Logging, ElementABC, metaclass=ElementMeta): """ Represents a UI element that serves as a central component for interaction. @@ -54,18 +70,23 @@ class Element(DriverMixin, InternalMixin, Logging, ElementABC): It dynamically adapts to different driver types (Playwright, Appium, Selenium) and provides a unified interface for UI interactions. """ + _object: str = 'element' + _initialized: bool = False + _is_locator_configured: bool = False + _base_cls: Type[PlayElement, MobileElement, WebElement] source_locator: Union[Locator, str] - _object = 'element' - _base_cls: Type[PlayElement, MobileElement, WebElement] - driver_wrapper: DriverWrapper - def __new__(cls, *args, **kwargs): instance = super(Element, cls).__new__(cls) set_instance_frame(instance) return instance + def __copy__(self): + new = object.__new__(self.__class__) + new.__dict__.update(self.__dict__) + return new + def __repr__(self): return self._repr_builder() @@ -73,15 +94,6 @@ def __call__(self, driver_wrapper: DriverWrapper = None): self.__full_init__(driver_wrapper=get_driver_wrapper_from_object(driver_wrapper)) return self - def __getattribute__(self, item): - if 'element' in item and not safe_getattribute(self, '_initialized'): - raise NotInitializedException( - f'{repr(self)} object is not initialized. ' - 'Try to initialize base object first or call it directly as a method' - ) - - return safe_getattribute(self, item) - def __init__( self, locator: Union[Locator, str], @@ -108,24 +120,15 @@ def __init__( an object containing it to be used for this element. :type driver_wrapper: typing.Union[DriverWrapper, typing.Any] """ - self._validate_inheritance() - - if parent: - if not isinstance(parent, (bool, Element)): - error = (f'The given "parent" arg of "{self.name}" should take an Element/Group ' - f'object or False for skip. Get {parent}') - raise ValueError(error) + self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) - self.locator = locator self.source_locator = locator - self.name = name if name else locator + self.locator = locator + self.name = name or locator self.parent = parent self.wait = wait - self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) - self._init_locals = getattr(self, '_init_locals', locals()) self._safe_setter('__base_obj_id', id(self)) - self._initialized = False if self.driver_wrapper: self.__full_init__(driver_wrapper) @@ -133,11 +136,10 @@ def __init__( def __full_init__(self, driver_wrapper: Any = None): self._driver_wrapper_given = bool(driver_wrapper) - if self._driver_wrapper_given and driver_wrapper != self.driver_wrapper: + if driver_wrapper and driver_wrapper != self.driver_wrapper: self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) self._modify_object() - self._modify_children() if not self._initialized: self.__init_base_class__() @@ -148,19 +150,57 @@ def __init_base_class__(self) -> None: :return: None """ - if isinstance(self.driver, PlaywrightDriver): + if self._driver_is_instance(PlaywrightDriver): self._base_cls = PlayElement - elif isinstance(self.driver, AppiumDriver): + elif self._driver_is_instance(AppiumDriver): self._base_cls = MobileElement - elif isinstance(self.driver, SeleniumDriver): + elif self._driver_is_instance(SeleniumDriver): self._base_cls = WebElement else: - raise DriverWrapperException(f'Cant specify {self.__class__.__name__}') + raise DriverWrapperException( + f'Cannot initialize {self.__class__.__name__}: ' + f'unsupported driver type "{type(self.driver).__name__}". ' + f'Expected Playwright, Appium or Selenium driver instance' + ) self._set_static(self._base_cls) self._base_cls.__init__(self) self._initialized = True + @property + def locator(self) -> str: + if not self._is_locator_configured: + self._set_locator() + + return self._locator + + @locator.setter + def locator(self, value: Union[Locator, str]) -> None: + self._log_locator = value + self._locator = value + + @property + def locator_type(self) -> str: + if not self._is_locator_configured: + self._set_locator() + + return self._locator_type + + @locator_type.setter + def locator_type(self, value: str) -> None: + self._locator_type = value + + @property + def log_locator(self) -> str: + if not self._is_locator_configured: + self._set_locator() + + return self._log_locator + + @log_locator.setter + def log_locator(self, value: str) -> None: + self._log_locator = value + # Following methods works same for both Selenium/Appium and Playwright APIs using internal methods # Elements interaction @@ -955,31 +995,41 @@ def _get_all_elements(self, sources: Union[tuple, list]) -> List[Any]: wrapped_object: Any = copy(self) wrapped_object.element = element wrapped_object._wrapped = True - set_parent_for_attr(wrapped_object, Element, with_copy=True) + wrapped_object.sub_elements = dict(self.sub_elements) + set_parent_for_attr(wrapped_object, with_copy=True) wrapped_elements.append(wrapped_object) return wrapped_elements - def _modify_children(self): + def _modify_sub_elements(self) -> None: """ - Initializing of attributes with type == Element. + Initializing of attributes with type == Element. Required for classes with base == Element. + + :return: :obj:`None` """ - initialize_objects(self, get_child_elements_with_names(self, Element), Element) + self.sub_elements = {} + + if type(self) is not self._element_cls: + self.sub_elements = extract_named_objects(self, Element) + initialize_objects(self, self.sub_elements) - def _modify_object(self): + def _modify_object(self) -> None: """ Modify current object if driver_wrapper is not given. Required for Page that placed into functions: - sets driver from previous object + + :return: :obj:`None` """ if not self._driver_wrapper_given: PreviousObjectDriver().set_driver_from_previous_object(self) - def _validate_inheritance(self): - cls = self.__class__ - mro = cls.__mro__ + @cached_property + def _element_cls(self) -> Type[Element]: + """ + Returns the `Element` class. + This can be overridden for performance optimizations. - for item in mro: - if is_page(item): - raise TypeError( - f"You cannot make an inheritance for {cls.__name__} from both Element/Group and Page objects") + :return: :obj:`typing.Type` [:class:`Element`] + """ + return Element diff --git a/mops/base/group.py b/mops/base/group.py index ba711190..9a737a63 100644 --- a/mops/base/group.py +++ b/mops/base/group.py @@ -1,15 +1,14 @@ from __future__ import annotations -from typing import Any, Union, List, Optional +from typing import Any, Union, Optional from mops.base.driver_wrapper import DriverWrapper from mops.base.element import Element from mops.mixins.objects.locator import Locator from mops.utils.internal_utils import ( set_parent_for_attr, - get_child_elements, initialize_objects, - get_child_elements_with_names + extract_named_objects ) @@ -27,11 +26,7 @@ class Group(Element): This class provides functionality for handling element locators, initialization with respect to the driver, and managing sub-elements within the group. """ - - _object = 'group' - - def __repr__(self): - return self._repr_builder() + _object: str = 'group' def __init__( self, @@ -63,7 +58,6 @@ def __init__( an object containing it to be used for entire group. :type driver_wrapper: typing.Union[DriverWrapper, typing.Any] """ - self._init_locals = locals() super().__init__( locator=locator, name=name, @@ -72,11 +66,11 @@ def __init__( driver_wrapper=driver_wrapper, ) - def _modify_children(self) -> None: + def _modify_sub_elements(self) -> None: """ Initializing of attributes with type == Group/Element. Required for classes with base == Group. """ - initialize_objects(self, get_child_elements_with_names(self, Element), Element) - set_parent_for_attr(self, Element) - self.child_elements: List[Element] = get_child_elements(self, Element) + self.sub_elements = extract_named_objects(self, Element) + initialize_objects(self, self.sub_elements) + set_parent_for_attr(self) diff --git a/mops/base/page.py b/mops/base/page.py index 3f871150..594eb782 100644 --- a/mops/base/page.py +++ b/mops/base/page.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Union, Any, List, Type +from functools import cached_property +from typing import Union, Any, Type from playwright.sync_api import Page as PlaywrightDriver from appium.webdriver.webdriver import WebDriver as AppiumDriver @@ -21,9 +22,7 @@ from mops.utils.internal_utils import ( WAIT_PAGE, initialize_objects, - get_child_elements_with_names, - get_child_elements, - is_element_instance, + extract_named_objects, ) @@ -43,7 +42,9 @@ class Page(DriverMixin, InternalMixin, Logging, PageABC): _object = 'page' _base_cls: Type[PlayPage, MobilePage, WebPage] - anchor: Element + url: str + log_locator: Union[str, None] = None + locator_type: Union[str, None] = None def __new__(cls, *args, **kwargs): instance = super(Page, cls).__new__(cls) @@ -70,25 +71,15 @@ def __init__( an object containing it to be used for entire page. :type driver_wrapper: typing.Union[DriverWrapper, typing.Any] """ - self._validate_inheritance() - self.driver_wrapper = get_driver_wrapper_from_object(driver_wrapper) - - self.anchor = Element(locator, name=name, driver_wrapper=self.driver_wrapper) - self.locator = self.anchor.locator - self.locator_type = self.anchor.locator_type - self.log_locator = self.anchor.log_locator - self.name = self.anchor.name - self.url = getattr(self, 'url', '') + self.locator = locator + self.name = name - self._init_locals = locals() self._modify_page_driver_wrapper(driver_wrapper) - self._modify_children() + self._modify_sub_elements() self._safe_setter('__base_obj_id', id(self)) - self.page_elements: List[Element] = get_child_elements(self, Element) - self.__init_base_class__() def __init_base_class__(self) -> None: @@ -97,18 +88,37 @@ def __init_base_class__(self) -> None: :return: None """ - if isinstance(self.driver, PlaywrightDriver): + if self._driver_is_instance(PlaywrightDriver): self._base_cls = PlayPage - elif isinstance(self.driver, AppiumDriver): + elif self._driver_is_instance(AppiumDriver): self._base_cls = MobilePage - elif isinstance(self.driver, SeleniumDriver): + elif self._driver_is_instance(SeleniumDriver): self._base_cls = WebPage else: - raise DriverWrapperException(f'Cant specify {Page.__name__}') + raise DriverWrapperException( + f'Cannot initialize {Page.__name__}: ' + f'unsupported driver type "{type(self.driver).__name__}". ' + f'Expected Playwright, Appium or Selenium driver instance' + ) self._set_static(self._base_cls) self._base_cls.__init__(self) + @cached_property + def anchor(self) -> Element: + """ + Return the anchor element of the page + + :return: :base:`.Element` + """ + anchor = Element(self.locator, name=self.name, driver_wrapper=self.driver_wrapper) + self.locator = anchor.locator + self.name = anchor.name + self.locator_type = anchor.locator_type + self.log_locator = anchor.log_locator + + return anchor + # Following methods works same for both Selenium/Appium and Playwright APIs using internal methods def reload_page(self, wait_page_load: bool = True) -> Page: @@ -159,7 +169,7 @@ def wait_page_loaded(self, silent: bool = False, timeout: Union[int, float] = WA self.anchor.wait_visibility(timeout=timeout, silent=True) - for element in self.page_elements: + for element in self.sub_elements.values(): if getattr(element, 'wait') is False: element.wait_hidden(timeout=timeout, silent=True) elif getattr(element, 'wait') is True: @@ -179,7 +189,7 @@ def is_page_opened(self, with_elements: bool = False, with_url: bool = False) -> result = True if with_elements: - for element in self.page_elements: + for element in self.sub_elements.values(): if getattr(element, 'wait'): result &= element.is_displayed(silent=True) if not result: @@ -187,17 +197,18 @@ def is_page_opened(self, with_elements: bool = False, with_url: bool = False) -> result &= self.anchor.is_displayed() - if self.url and with_url: + if with_url: result &= self.driver_wrapper.current_url == self.url return result - def _modify_children(self): + def _modify_sub_elements(self): """ Initializing of attributes with type == Element. Required for classes with base == Page. """ - initialize_objects(self, get_child_elements_with_names(self, Element), Element) + self.sub_elements = extract_named_objects(self, Element) + initialize_objects(self, self.sub_elements) def _modify_page_driver_wrapper(self, driver_wrapper: Any): """ @@ -206,12 +217,3 @@ def _modify_page_driver_wrapper(self, driver_wrapper: Any): """ if not driver_wrapper: PreviousObjectDriver().set_driver_from_previous_object(self) - - def _validate_inheritance(self): - cls = self.__class__ - mro = cls.__mro__ - - for item in mro: - if is_element_instance(item): - raise TypeError( - f"You cannot make an inheritance for {cls.__name__} from both Page and Group/Element objects") diff --git a/mops/mixins/internal_mixin.py b/mops/mixins/internal_mixin.py index e616ac61..f06b602e 100644 --- a/mops/mixins/internal_mixin.py +++ b/mops/mixins/internal_mixin.py @@ -4,8 +4,8 @@ from typing import Any from mops.utils.internal_utils import ( - get_child_elements_with_names, - get_all_attributes_from_object, + extract_named_objects, + extract_all_named_objects, ) @@ -26,11 +26,25 @@ def get_element_info(element: Any, label: str = 'Selector=') -> str: return f"{label}'{selector}'" if label else selector @lru_cache(maxsize=16) -def get_static(cls: Any): - return get_child_elements_with_names(cls).items() +def get_static_attributes(cls: Any) -> dict: + return extract_named_objects(cls) + +@lru_cache(maxsize=32) +def get_all_static_attributes(cls: Any) -> dict: + return extract_all_named_objects(cls) + +@lru_cache(maxsize=16) +def get_driver_instance(driver_type, instance) -> bool: + return issubclass(driver_type, instance) + class InternalMixin: + driver: None + + def _driver_is_instance(self, instance): + return get_driver_instance(type(self.driver), instance) + def _safe_setter(self, var: str, value: Any): if not hasattr(self, var): setattr(self, var, value) @@ -41,13 +55,18 @@ def _set_static(self: Any, cls) -> None: :return: None """ - data = { - name: value for name, value in get_static(cls) - if name not in get_all_attributes_from_object(self).keys() - }.items() + current_obj_cls = self.__class__ + + if current_obj_cls.__dict__.get('_configured'): + return + + existing_attrs = set(get_all_static_attributes(current_obj_cls)) + + for name, value in get_static_attributes(cls).items(): + if name not in existing_attrs: + setattr(current_obj_cls, name, value) - for name, item in data: - setattr(self.__class__, name, item) + current_obj_cls._configured = True def _repr_builder(self: Any): class_name = self.__class__.__name__ diff --git a/mops/mixins/native_context.py b/mops/mixins/native_context.py index a86c8951..fbe12a85 100644 --- a/mops/mixins/native_context.py +++ b/mops/mixins/native_context.py @@ -80,10 +80,9 @@ def get_bottom_bar_height(self) -> int: if not self.custom_bottom_bar_locator: ios_version = float(self.driver_wrapper.driver.caps.get('platformVersion', 18.2)) - if ios_version >= 18.2: - self.bottom_bar.locator = self.ios_18_bottom_bar_locator - elif ios_version >= 26.0: + if ios_version >= 26.0: self.bottom_bar.locator = self.ios_26_bottom_bar_locator - + elif ios_version >= 18.2: + self.bottom_bar.locator = self.ios_18_bottom_bar_locator return self.bottom_bar.size.height diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index 1ab968b7..08f7889a 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -7,13 +7,14 @@ from mops.keyboard_keys import KeyboardKeys from playwright.sync_api import Error from playwright.sync_api import Page as PlaywrightPage -from playwright.sync_api import Locator, Page, Browser, BrowserContext +from playwright.sync_api import Locator from mops.mixins.objects.size import Size from mops.mixins.objects.location import Location from mops.utils.decorators import retry from mops.utils.selector_synchronizer import get_platform_locator, set_playwright_locator from mops.abstraction.element_abc import ElementABC +from mops.exceptions import NotInitializedException from mops.exceptions import InvalidSelectorException from mops.utils.logs import Logging from mops.shared_utils import cut_log_data, get_image @@ -26,18 +27,10 @@ class PlayElement(ElementABC, Logging, ABC): - instance: Browser - context: BrowserContext - driver: Page parent: Union[ElementABC, PlayElement] - _element: Locator = None - def __init__(self): # noqa - """ - Initializing of web element with playwright driver - """ - self.locator = get_platform_locator(self) - set_playwright_locator(self) + _initialized: bool + _element: Locator = None # Element @@ -50,7 +43,14 @@ def element(self) -> Locator: :param: kwargs: kwargs from Locator object :return: Locator """ + if not self._initialized: + raise NotInitializedException( + f'{repr(self)} object is not initialized. ' + 'Try to initialize base object first or call it directly as a method' + ) + element = self._element + if not element: driver = self._get_base() element = driver.locator(self.locator) @@ -450,3 +450,8 @@ def _first_element(self): :return: first element """ return self.element.first + + def _set_locator(self): + self.locator = get_platform_locator(self) + set_playwright_locator(self) + self._is_locator_configured = True diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 8fe9c678..873266ce 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -32,7 +32,7 @@ DriverWrapperException, NoSuchElementException, ElementNotInteractableException, - NoSuchParentException, + NoSuchParentException, NotInitializedException, ) if TYPE_CHECKING: @@ -41,8 +41,9 @@ class CoreElement(ElementABC, ABC): - parent: Union[Element] - locator_type: str + parent: Union[Element, CoreElement] + + _initialized: bool _element: Union[None, SeleniumWebElement, AppiumWebElement] = None _cached_element: Union[None, SeleniumWebElement, AppiumWebElement] = None @@ -55,6 +56,12 @@ def element(self) -> SeleniumWebElement: :return: SeleniumWebElement """ + if not self._initialized: + raise NotInitializedException( + f'{repr(self)} object is not initialized. ' + 'Try to initialize base object first or call it directly as a method' + ) + return self._get_element() @element.setter diff --git a/mops/selenium/elements/mobile_element.py b/mops/selenium/elements/mobile_element.py index 9e9aa4f9..ae9d704c 100644 --- a/mops/selenium/elements/mobile_element.py +++ b/mops/selenium/elements/mobile_element.py @@ -14,13 +14,6 @@ class MobileElement(CoreElement, ABC): - def __init__(self): - """ - Initializing of mobile element with appium driver - """ - self.locator = get_platform_locator(self) - set_appium_selector(self) - def click_outside(self, x: int = -5, y: int = -5) -> MobileElement: """ Perform a click outside the current element, by default 5px left and above it. @@ -174,3 +167,8 @@ def _element_box(self) -> tuple: element_location.x + element_size.width, element_location.y + element_size.height, ) + + def _set_locator(self): + self.locator = get_platform_locator(self) + set_appium_selector(self) + self._is_locator_configured = True diff --git a/mops/selenium/elements/web_element.py b/mops/selenium/elements/web_element.py index 833da7e4..72d8ae39 100644 --- a/mops/selenium/elements/web_element.py +++ b/mops/selenium/elements/web_element.py @@ -13,13 +13,6 @@ class WebElement(CoreElement, ABC): - def __init__(self): - """ - Initializing of web element with selenium driver - """ - self.locator = get_platform_locator(self) - set_selenium_selector(self) - def click(self, *, force_wait: bool = True, **kwargs) -> WebElement: """ Clicks on the element. @@ -128,3 +121,8 @@ def click_into_center(self, silent: bool = False) -> WebElement: self.driver_wrapper.click_by_coordinates(x=x, y=y, silent=True) return self + + def _set_locator(self): + self.locator = get_platform_locator(self) + set_selenium_selector(self) + self._is_locator_configured = True diff --git a/mops/shared_utils.py b/mops/shared_utils.py index 687064e3..8337bb52 100644 --- a/mops/shared_utils.py +++ b/mops/shared_utils.py @@ -39,7 +39,7 @@ def resize_image(image1: str, image2: str, img_format='JPEG') -> bytes: img2 = Image.open(image2) width, height = img2.size - img1.resize((width, height), Image.Resampling.LANCZOS) + img1 = img1.resize((width, height), Image.Resampling.LANCZOS) return save_image(img1, img_format) @@ -67,6 +67,19 @@ def shell_command(cmd, **kwargs): return process +def get_all_sub_elements(instance, sub_elements: list = None) -> list: + if sub_elements is None: + sub_elements = [] + + if hasattr(instance, 'sub_elements') and instance.sub_elements: + for key, sub_element in instance.sub_elements.items(): + sub_elements.append(sub_element) + if hasattr(sub_element, 'sub_elements') and sub_element.sub_elements: + get_all_sub_elements(sub_element, sub_elements) + + return sub_elements + + def cut_log_data(data: str, length=50) -> str: """ Cut given data for reducing log length diff --git a/mops/utils/decorators.py b/mops/utils/decorators.py index a6abab75..a7b2607e 100644 --- a/mops/utils/decorators.py +++ b/mops/utils/decorators.py @@ -116,7 +116,7 @@ def wrapper( if not result.execution_result: raise ContinuousWaitException( - f'The continuous "{method.__name__}" of the "{self.name}" is no met ' + f'The continuous "{method.__name__}" of the "{self.name}" is not met ' f'after {(time.time() - start_time):.2f} seconds' ) diff --git a/mops/utils/internal_utils.py b/mops/utils/internal_utils.py index 89937451..b061f25a 100644 --- a/mops/utils/internal_utils.py +++ b/mops/utils/internal_utils.py @@ -3,6 +3,7 @@ import sys import inspect from copy import copy +from typing import TYPE_CHECKING from functools import lru_cache from typing import Any, Union, Callable @@ -11,6 +12,12 @@ from mops.exceptions import DriverWrapperException as MopsDriverWrapperException from mops.mixins.objects.size import Size +if TYPE_CHECKING: + from mops.base.element import Element + from mops.base.group import Group + from mops.base.page import Page + + WAIT_METHODS_DELAY = 0.1 WAIT_UNIT = 1 WAIT_EL = 10 @@ -19,9 +26,9 @@ WAIT_PAGE = 15 -all_tags = {'h1', 'h2', 'h3', 'h4', 'h5', 'head', 'body', 'input', 'section', 'button', 'a', 'link', 'header', 'div', - 'textarea', 'svg', 'circle', 'iframe', 'label', 'p', 'tr', 'th', 'table', 'tbody', 'td', 'select', 'nav', - 'li', 'form', 'footer', 'frame', 'area', 'span', 'video'} +all_tags = frozenset({'h1', 'h2', 'h3', 'h4', 'h5', 'head', 'body', 'input', 'section', 'button', 'a', 'link', 'header', + 'div', 'textarea', 'svg', 'circle', 'iframe', 'label', 'p', 'tr', 'th', 'table', 'tbody', 'td', + 'select', 'nav', 'li', 'form', 'footer', 'frame', 'area', 'span', 'video'}) def get_dict(obj: Any): @@ -59,10 +66,6 @@ def get_timeout_in_ms(timeout: Union[int, float]): return validate_timeout(timeout) * 1000 -def safe_getattribute(obj, item): - return object.__getattribute__(obj, item) - - def get_frame(frame=1): """ Get frame by given id @@ -93,78 +96,70 @@ def is_driver_wrapper(obj: Any) -> bool: return getattr(obj, '_object', None) == 'driver_wrapper' -def initialize_objects(current_object, objects: dict, cls: Any): +def initialize_objects(current_object: Union[Element, Group, Page], sub_elements: dict): """ Copy objects and initializing them with driver_wrapper from current object :param current_object: list of objects to initialize - :param objects: list of objects to initialize - :param cls: class of initializing objects + :param sub_elements: list of objects to initialize :return: None """ - for name, obj in objects.items(): + for name, obj in sub_elements.items(): copied_obj = copy(obj) - promote_parent_element(copied_obj, current_object, cls) + + promote_parent_element(copied_obj, current_object) + sub_elements[name] = copied_obj setattr(current_object, name, copied_obj(driver_wrapper=current_object.driver_wrapper)) - initialize_objects(copied_obj, get_child_elements_with_names(copied_obj, cls), cls) + copied_obj._modify_sub_elements() -def set_parent_for_attr(base_obj: object, instance_class: Union[type, tuple], with_copy: bool = False): +def set_parent_for_attr(current_object: Element, with_copy: bool = False): """ Sets parent for all Elements/Group of given class. Should be called ONLY in Group object or all_elements method. Copy of objects will be executed if with_copy is True. Required for all_elements method - :param instance_class: attribute class to looking for - :param base_obj: object of attribute + :param current_object: object of attribute :param with_copy: copy child object or not :return: self """ - child_elements = get_child_elements_with_names(base_obj, instance_class).items() + current_is_group = is_group(current_object) - for name, child in child_elements: + for name, obj in current_object.sub_elements.items(): if with_copy: - child = copy(child) - - if (is_group(base_obj) and child.parent is None) or is_group(child.parent): - child.parent = base_obj + obj = copy(obj) + current_object.sub_elements[name] = obj + setattr(current_object, name, obj) - if with_copy: - setattr(base_obj, name, child) + if (current_is_group and obj.parent is None) or is_group(obj.parent): + obj.parent = current_object - set_parent_for_attr(child, instance_class, with_copy) + if getattr(obj, 'sub_elements', None): + set_parent_for_attr(obj, with_copy) -def promote_parent_element(obj: Any, base_obj: Any, cls: Any): +def promote_parent_element(obj: Any, base_obj: Any): """ Promote parent object in Element if parent is another Element :param obj: any element :param base_obj: base object of element: Page/Group instance - :param cls: element class :return: None """ - initial_parent = getattr(obj, 'parent', None) + initial_parent = obj.parent if not initial_parent: return None - if is_element_instance(initial_parent) and initial_parent != base_obj: - for el in get_child_elements(base_obj, cls): - if obj.parent.__base_obj_id == el.__base_obj_id: + if is_element_instance(initial_parent) and initial_parent is not base_obj: + parent_id = initial_parent.__base_obj_id + for el in base_obj.sub_elements.values(): + if parent_id == el.__base_obj_id: obj.parent = el + break -def get_child_elements(obj: object, instance: Union[type, tuple]) -> list: - """ - Return objects of this object by instance - - :returns: list of page elements and page objects - """ - return list(get_child_elements_with_names(obj, instance).values()) - - -def get_child_elements_with_names(obj: Any, instance: Union[type, tuple] = None) -> dict: +def extract_named_objects(obj: Any, instance: Union[type, tuple] = None) -> dict: """ Return all objects of given object or by instance Removing parent attribute from list to avoid infinite recursion and all dunder attributes @@ -173,66 +168,51 @@ def get_child_elements_with_names(obj: Any, instance: Union[type, tuple] = None) """ elements = {} - for attribute, value in get_all_attributes_from_object(obj).items(): - if instance and isinstance(value, instance) or not instance: - if attribute != 'parent' and not attribute.startswith('__') and not attribute.endswith('__'): - elements.update({attribute: value}) + for attribute, value in extract_all_named_objects(obj).items(): + if not instance or isinstance(value, instance): + if not attribute.startswith('__') and attribute != 'parent': + elements[attribute] = value return elements -def get_all_attributes_from_object(reference_obj: Any) -> dict: +def extract_all_named_objects(reference_obj: Any) -> dict: """ - Get attributes from given object and all its bases + Get attributes from the given object and all its bases. :param reference_obj: reference object :return: dict of all attributes """ items = {} - - if not reference_obj: - return items - reference_class = reference_obj if inspect.isclass(reference_obj) else reference_obj.__class__ - all_bases = list(inspect.getmro(reference_class)) - all_bases.reverse() # Reverse needed for collect subclasses attributes as base one - - for parent_class in all_bases: + all_bases = inspect.getmro(reference_class) - if 'ABC' in str(parent_class) or parent_class == object: + for parent_class in all_bases[-2::-1]: # Skip the reference class itself + if parent_class is object or 'ABC' in parent_class.__name__: continue - items.update(dict(parent_class.__dict__)) + items.update(get_attributes_from_object(parent_class)) - return {**items, **get_attributes_from_object(reference_obj)} + items.update(get_attributes_from_object(reference_class)) + items.update(get_attributes_from_object(reference_obj)) + + return items def get_attributes_from_object(reference_obj: Any) -> dict: """ - Get attributes from given object + Get attributes from the given object. - :param reference_obj: - :return: + :param reference_obj: reference object + :return: dict of attributes """ - items = {} - - if not reference_obj: - return items - - if not inspect.isclass(reference_obj): - items.update(dict(reference_obj.__class__.__dict__)) - - items.update(dict(reference_obj.__dict__)) - - return items + return dict(reference_obj.__dict__) def is_target_on_screen(x: int, y: int, possible_range: Size): """ Check is given coordinates fit into given range - An safe value will be applied: - 1 - Due to usage of range - 2 - Due to rounding a number when get size/location of element + An safe value will be applied due to rounding a number when get size/location of element :param x: x coordinate :param y: y coordinate @@ -240,9 +220,7 @@ def is_target_on_screen(x: int, y: int, possible_range: Size): :return: bool """ safe_value = 2 - is_x_on_screen = x in range(possible_range.width + safe_value) - is_y_on_screen = y in range(possible_range.height + safe_value) - return is_x_on_screen and is_y_on_screen + return 0 <= x < possible_range.width + safe_value and 0 <= y < possible_range.height + safe_value def calculate_coordinate_to_click(element: Any, x: int = 0, y: int = 0) -> tuple: diff --git a/mops/utils/selector_synchronizer.py b/mops/utils/selector_synchronizer.py index 0cc9d17f..60b1a543 100644 --- a/mops/utils/selector_synchronizer.py +++ b/mops/utils/selector_synchronizer.py @@ -52,11 +52,11 @@ def _set_selenium_compatibility_id_locator(obj: Any, split: bool = True) -> Any: - locator = obj.locator.split(f"{LocatorType.ID}=")[-1] if split else obj.locator + locator = obj._locator.split(f"{LocatorType.ID}=")[-1] if split else obj._locator - obj.locator = f'[{LocatorType.ID}="{locator}"]' - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.ID}={locator}' + obj._locator = f'[{LocatorType.ID}="{locator}"]' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.ID}={locator}' def get_platform_locator(obj: Any): @@ -66,7 +66,7 @@ def get_platform_locator(obj: Any): :param obj: Page/Group/Element :return: current platform locator """ - locator: Union[Locator, str] = obj.locator + locator: Union[Locator, str] = obj._locator if type(locator) is str or not obj.driver_wrapper: return locator @@ -94,23 +94,23 @@ def set_selenium_selector(obj: Any): """ Sets selenium locator & locator type """ - locator = obj.locator.strip() - obj.log_locator = locator + locator = obj._locator.strip() + obj._log_locator = locator # Checking the supported locators if locator.startswith(f"{LocatorType.XPATH}="): - obj.locator = obj.locator.split(f"{LocatorType.XPATH}=")[-1] - obj.locator_type = By.XPATH + obj._locator = obj._locator.split(f"{LocatorType.XPATH}=")[-1] + obj._locator_type = By.XPATH elif locator.startswith(f"{LocatorType.TEXT}="): - locator = obj.locator.split(f"{LocatorType.TEXT}=")[-1] - obj.locator = f'//*[contains(text(), "{locator}")]' - obj.locator_type = By.XPATH + locator = obj._locator.split(f"{LocatorType.TEXT}=")[-1] + obj._locator = f'//*[contains(text(), "{locator}")]' + obj._locator_type = By.XPATH elif locator.startswith(f"{LocatorType.CSS}="): - obj.locator = obj.locator.split(f"{LocatorType.CSS}=")[-1] - obj.locator_type = By.CSS_SELECTOR + obj._locator = obj._locator.split(f"{LocatorType.CSS}=")[-1] + obj._locator_type = By.CSS_SELECTOR elif locator.startswith(f"{LocatorType.ID}="): _set_selenium_compatibility_id_locator(obj) @@ -118,16 +118,16 @@ def set_selenium_selector(obj: Any): # Checking the regular locators elif locator.startswith(_XPATH_MATCH): - obj.locator_type = By.XPATH - obj.log_locator = f'{LocatorType.XPATH}={locator}' + obj._locator_type = By.XPATH + obj._log_locator = f'{LocatorType.XPATH}={locator}' elif locator.startswith(_CSS_MATCH) or re.search(_CSS_REGEXP, locator): - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.CSS}={locator}' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.CSS}={locator}' elif locator in all_tags or all(tag in all_tags for tag in locator.split()): - obj.locator_type = By.CSS_SELECTOR - obj.log_locator = f'{LocatorType.CSS}={locator}' + obj._locator_type = By.CSS_SELECTOR + obj._log_locator = f'{LocatorType.CSS}={locator}' # Default to ID if nothing else matches @@ -139,34 +139,34 @@ def set_playwright_locator(obj: Any): """ Sets playwright locator & locator type """ - locator: str = obj.locator.strip() + locator: str = obj._locator.strip() - obj.log_locator = locator + obj._log_locator = locator # Checking the supported locators if locator.startswith(_DEFAULT_MATCH): - obj.locator_type = locator.partition('=')[0] + obj._locator_type = locator.partition('=')[0] return # Checking the regular locators elif locator.startswith(_XPATH_MATCH): - obj.locator_type = LocatorType.XPATH + obj._locator_type = LocatorType.XPATH elif locator.startswith(_CSS_MATCH) or re.search(_CSS_REGEXP, locator): - obj.locator_type = LocatorType.CSS + obj._locator_type = LocatorType.CSS elif locator in all_tags or all(tag in all_tags for tag in locator.split()): - obj.locator_type = LocatorType.CSS + obj._locator_type = LocatorType.CSS # Default to ID if nothing else matches else: - obj.locator_type = LocatorType.ID + obj._locator_type = LocatorType.ID - obj.locator = f'{obj.locator_type}={locator}' - obj.log_locator = obj.locator + obj._locator = f'{obj._locator_type}={locator}' + obj._log_locator = obj._locator def set_appium_selector(obj: Any): @@ -175,13 +175,13 @@ def set_appium_selector(obj: Any): """ set_selenium_selector(obj) - locator: str = obj.locator.strip() + locator: str = obj._locator.strip() # Mobile com.android selector if ':id/' in locator and not locator.startswith(_APPIUM_MATCH): _set_selenium_compatibility_id_locator(obj) elif locator.startswith(_APPIUM_LOCATOR_TYPES): partition = locator.partition('=') - obj.locator_type = partition[0] - obj.locator = partition[-1] - obj.log_locator = locator + obj._locator_type = partition[0] + obj._locator = partition[-1] + obj._log_locator = locator diff --git a/mops/visual_comparison.py b/mops/visual_comparison.py index 8fe1f9a0..a467fbb6 100644 --- a/mops/visual_comparison.py +++ b/mops/visual_comparison.py @@ -158,7 +158,7 @@ def assert_screenshot( return self image = cv2.imread(reference_file) - if isinstance(image, type(None)): + if image is None: self._save_screenshot(reference_file, **screenshot_params) if self.visual_reference_generation or self.soft_visual_reference_generation: diff --git a/tests/adata/pages/playground_main_page.py b/tests/adata/pages/playground_main_page.py index c6071e1c..ceb4074e 100644 --- a/tests/adata/pages/playground_main_page.py +++ b/tests/adata/pages/playground_main_page.py @@ -58,5 +58,6 @@ def navigate_to_keyboard_page(self): class Card(Group): def __init__(self, driver_wrapper=None): super().__init__('.card', name='action cards', driver_wrapper=driver_wrapper) + self.any_button = Element('a', name='any button') button = Element('a', name='proceed card button') diff --git a/tests/adata/pull_ci_artifacts.py b/tests/adata/pull_ci_artifacts.py index 96cff166..337ab1ad 100644 --- a/tests/adata/pull_ci_artifacts.py +++ b/tests/adata/pull_ci_artifacts.py @@ -99,8 +99,8 @@ def download_artefact_and_replace_references(self): print(f"Finding PR associated with commit {self.launch_args.commit_sha}...") pr_number = self._api_request(f"{bae_api_url}/search/issues?q=repo:{REPO}+sha:{self.launch_args.commit_sha}+is:pr")['items'][0]['number'] branch_name = self._api_request(f"{repos_url}/{REPO}/pulls/{pr_number}")['head']['ref'] - runs_response = self._api_request(f"{repos_url}/{REPO}/actions/runs?per_page=100&event=push&branch={branch_name}") - run_ids = [run["id"] for run in runs_response.get("workflow_runs", []) if any(pr.get("number") == pr_number for pr in run.get("pull_requests", []))] + runs_response = self._api_request(f"{repos_url}/{REPO}/actions/runs?per_page=100&branch={branch_name}&head_sha={self.launch_args.commit_sha}") + run_ids = [run["id"] for run in runs_response.get("workflow_runs", [])] for run_id in run_ids: artifacts_response = self._api_request(f"{repos_url}/{REPO}/actions/runs/{run_id}/artifacts") for artifact in artifacts_response.get("artifacts", []): diff --git a/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_middle_hidden_macos_selenium_safari.png b/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_middle_hidden_macos_selenium_safari.png index 3d532f4b..d8446103 100644 Binary files a/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_middle_hidden_macos_selenium_safari.png and b/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_middle_hidden_macos_selenium_safari.png differ diff --git a/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_sides_hidden_macos_selenium_safari.png b/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_sides_hidden_macos_selenium_safari.png index 0f8ad940..06c068a2 100644 Binary files a/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_sides_hidden_macos_selenium_safari.png and b/tests/adata/visual/reference/test_assert_screenshot_hide_driver_elements_entire_screen_sides_hidden_macos_selenium_safari.png differ diff --git a/tests/static_tests/integration/test_all_elements.py b/tests/static_tests/integration/test_all_elements.py new file mode 100644 index 00000000..97f725f9 --- /dev/null +++ b/tests/static_tests/integration/test_all_elements.py @@ -0,0 +1,47 @@ +from mock.mock import MagicMock + +from mops.base.element import Element +from mops.base.group import Group + + +class Card(Group): + def __init__(self, driver_wrapper=None): + super().__init__('.card', name='card', driver_wrapper=driver_wrapper) + self.any_button = Element('a', name='any button') + + button = Element('a', name='button') + + +def test_all_elements_inside_all_elements(mocked_selenium_driver): + """all_elements when parent of Element is Group with instance-level sub-element (static analog)""" + mock_sources = [MagicMock(), MagicMock(), MagicMock()] + all_cards = Card()._get_all_elements(mock_sources) + + for card in all_cards: + assert 'any_button' in card.sub_elements, 'instance-level any_button must be in sub_elements' + assert card.any_button.parent is card + + mock_button_sources = [MagicMock(), MagicMock()] + buttons = card.any_button._get_all_elements(mock_button_sources) + for button in buttons: + assert button.parent is not None + + +def test_all_elements_init_sub_element_isolated(mocked_selenium_driver): + """Each wrapped card must have its own independent any_button instance""" + mock_sources = [MagicMock(), MagicMock(), MagicMock()] + all_cards = Card()._get_all_elements(mock_sources) + + for i, card_a in enumerate(all_cards): + for j, card_b in enumerate(all_cards): + if i != j: + assert card_a.any_button is not card_b.any_button + + +def test_all_elements_class_sub_element_has_parent(mocked_selenium_driver): + """Class-level button must also have correct parent on each wrapped card""" + mock_sources = [MagicMock(), MagicMock()] + all_cards = Card()._get_all_elements(mock_sources) + + for card in all_cards: + assert card.button.parent is card diff --git a/tests/static_tests/integration/test_child_elements.py b/tests/static_tests/integration/test_child_elements.py index 5ebd4969..4cedd772 100644 --- a/tests/static_tests/integration/test_child_elements.py +++ b/tests/static_tests/integration/test_child_elements.py @@ -29,8 +29,8 @@ def test_child_elements(mocked_selenium_driver): section3 = Section3() assert section3.some_element assert section3.some_element._initialized # noqa - assert section3.child_elements + assert section3.sub_elements assert section2.some_element assert section2.some_element._initialized # noqa - assert section2.child_elements + assert section2.sub_elements diff --git a/tests/static_tests/integration/test_inheritance.py b/tests/static_tests/integration/test_inheritance.py deleted file mode 100644 index ea1cbdaa..00000000 --- a/tests/static_tests/integration/test_inheritance.py +++ /dev/null @@ -1,36 +0,0 @@ -from mops.base.group import Group -from mops.base.page import Page - - -class WrapperGroup(Group): - pass - - -class SomePage(Page, WrapperGroup): - pass - - -def test_unexpected_page_inheritance(mocked_selenium_driver): - try: - SomePage() - except TypeError: - pass - else: - raise Exception('Unexpected behaviour') - - -class WrapperPage(Page): - pass - - -class Section(Group, WrapperPage): - pass - - -def test_unexpected_element_or_group_inheritance(mocked_selenium_driver): - try: - Section() - except TypeError: - pass - else: - raise Exception('Unexpected behaviour') diff --git a/tests/static_tests/integration/test_initialize_objects.py b/tests/static_tests/integration/test_initialize_objects.py index 8b2de7f4..658277e8 100644 --- a/tests/static_tests/integration/test_initialize_objects.py +++ b/tests/static_tests/integration/test_initialize_objects.py @@ -1,3 +1,5 @@ +import re + from mops.base.element import Element from mops.base.group import Group from mops.exceptions import NotInitializedException @@ -23,13 +25,18 @@ def test_initialize_objects(mocked_selenium_driver): assert root_section.section.el._initialized try: RootSection.section.el.element - except NotInitializedException: - pass + except NotInitializedException as exc: + pattern = r'Element object at .* object is not initialized. Try to initialize base object first or call it directly as a method' + assert re.search(pattern, exc.msg) else: raise AssertionError('NotInitializedException should be raised') def test_initialize_object_manually(mocked_selenium_driver): """ covers __call__ of Element """ - RootSection.section.el() + assert RootSection.section.el.driver_wrapper is None + assert RootSection.section.el._initialized == False + called_object = RootSection.section.el() + assert called_object.driver_wrapper == mocked_selenium_driver + assert called_object._initialized == True diff --git a/tests/static_tests/integration/test_sub_elements_isolation.py b/tests/static_tests/integration/test_sub_elements_isolation.py new file mode 100644 index 00000000..26cd51c1 --- /dev/null +++ b/tests/static_tests/integration/test_sub_elements_isolation.py @@ -0,0 +1,110 @@ +from mops.base.element import Element +from mops.base.group import Group +from mops.base.page import Page + + +class BaseRow(Group): + name_cell = Element('css=.name', name='name_cell') + status_cell = Element('css=.status', name='status_cell') + + +class RowA(BaseRow): + extra_a = Element('css=.extra-a', name='extra_a') + + +class RowB(BaseRow): + extra_b = Element('css=.extra-b', name='extra_b') + + +class TableA(Group): + row = RowA('css=tr.a', name='rowA') + + +class TableB(Group): + row = RowB('css=tr.b', name='rowB') + + +# -- Class level -- + +class PageClassLevel(Page): + table_a = TableA('css=.tbl-a', name='tableA') + table_b = TableB('css=.tbl-b', name='tableB') + + +# -- Init level -- + +class PageInitLevel(Page): + def __init__(self): + self.table_a = TableA('css=.tbl-a', name='tableA') + self.table_b = TableB('css=.tbl-b', name='tableB') + super().__init__('body', name='PageInitLevel') + + +def test_class_level_sub_elements_not_leaking(mocked_selenium_driver): + """Sub elements of nested groups should not leak to parent level (class level)""" + page = PageClassLevel() + assert list(page.table_a.sub_elements.keys()) == ['row'] + assert list(page.table_b.sub_elements.keys()) == ['row'] + + +def test_init_level_sub_elements_not_leaking(mocked_selenium_driver): + """Sub elements of nested groups should not leak to parent level (__init__ level)""" + page = PageInitLevel() + assert list(page.table_a.sub_elements.keys()) == ['row'] + assert list(page.table_b.sub_elements.keys()) == ['row'] + + +def test_class_level_correct_row_types(mocked_selenium_driver): + """Nested groups should preserve their correct types (class level)""" + page = PageClassLevel() + assert isinstance(page.table_a.row, RowA) + assert isinstance(page.table_b.row, RowB) + + +def test_init_level_correct_row_types(mocked_selenium_driver): + """Nested groups should preserve their correct types (__init__ level)""" + page = PageInitLevel() + assert isinstance(page.table_a.row, RowA) + assert isinstance(page.table_b.row, RowB) + + +def test_class_level_row_sub_elements(mocked_selenium_driver): + """Row sub elements should contain only own and inherited elements (class level)""" + page = PageClassLevel() + assert 'name_cell' in page.table_a.row.sub_elements + assert 'status_cell' in page.table_a.row.sub_elements + assert 'extra_a' in page.table_a.row.sub_elements + assert 'extra_b' not in page.table_a.row.sub_elements + + assert 'name_cell' in page.table_b.row.sub_elements + assert 'status_cell' in page.table_b.row.sub_elements + assert 'extra_b' in page.table_b.row.sub_elements + assert 'extra_a' not in page.table_b.row.sub_elements + + +def test_init_level_row_sub_elements(mocked_selenium_driver): + """Row sub elements should contain only own and inherited elements (__init__ level)""" + page = PageInitLevel() + assert 'name_cell' in page.table_a.row.sub_elements + assert 'status_cell' in page.table_a.row.sub_elements + assert 'extra_a' in page.table_a.row.sub_elements + assert 'extra_b' not in page.table_a.row.sub_elements + + assert 'name_cell' in page.table_b.row.sub_elements + assert 'status_cell' in page.table_b.row.sub_elements + assert 'extra_b' in page.table_b.row.sub_elements + assert 'extra_a' not in page.table_b.row.sub_elements + + +def test_class_level_no_cross_contamination(mocked_selenium_driver): + """Elements from one table's row should not appear on another table (class level)""" + page = PageClassLevel() + assert not hasattr(page.table_a, 'extra_b') + assert not hasattr(page.table_b, 'extra_a') + + +def test_init_level_no_cross_contamination(mocked_selenium_driver): + """Elements from one table's row should not appear on another table (__init__ level)""" + page = PageInitLevel() + assert not hasattr(page.table_a, 'extra_b') + assert not hasattr(page.table_b, 'extra_a') diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py new file mode 100644 index 00000000..a0d873e2 --- /dev/null +++ b/tests/static_tests/performance/test_overall_performance.py @@ -0,0 +1,183 @@ +import cProfile +import pstats +import sys +import tracemalloc +import time + +import pytest + +from mops.base.element import Element +from mops.base.group import Group +from mops.base.page import Page +from mops.shared_utils import get_all_sub_elements + +section_sub_elements_count = 5000 + + +class AnotherSection1(Group): + + def __init__(self): + super().__init__('AnotherSection') + + +class AnotherSection(Group): + + def __init__(self): + super().__init__('AnotherSection') + + another_some_element = Element('AnotherSection_another_some_element') + + +class SomeSection(Group): + + def __init__(self, locator): + super().__init__(locator) + + AnotherSection = AnotherSection() + + +class SomePage(Page): + + def __init__(self, driver_wrapper = None): + super().__init__('SomePage', driver_wrapper=driver_wrapper) + + + +@pytest.fixture(scope='module') +def set_elements_class_var_objects(): + for _i in range(section_sub_elements_count): + _element = Element(f'{_i}_element') + setattr(AnotherSection1, _element.name, _element) + + +@pytest.mark.parametrize('case', range(5)) +def test_performance_element_initialisation(mocked_selenium_driver, case, set_elements_class_var_objects): + tracemalloc.start() + start_cpu = time.process_time() + + with cProfile.Profile() as pr: + section = AnotherSection1() + + end_cpu = time.process_time() + cpu_time = end_cpu - start_cpu # CPU time used + + peak_mem = tracemalloc.get_traced_memory()[1] / 1024**2 + tracemalloc.stop() + + stats: pstats.Stats = pstats.Stats(pr) + stats.strip_dirs().sort_stats("time").print_stats(20) + + init_without_profiling_start_timestamp = time.time() + AnotherSection1() + init_without_profiling_stop_timestamp = time.time() - init_without_profiling_start_timestamp + + print('stats.total_tt=', stats.total_tt) + print('peak_mem=', peak_mem) + print('cpu_time=', cpu_time) + print('init_without_profiling_stop_timestamp=', init_without_profiling_stop_timestamp) + + expected_peak_mem = 4.7 + expected_init_duration = 0.4 + init_without_profiling_expected = 0.1 + + if sys.version_info >= (3, 9): + expected_peak_mem = 4.7 + expected_init_duration = 0.4 + init_without_profiling_expected = 0.13 + if sys.version_info >= (3, 10): + expected_peak_mem = 4.6 + expected_init_duration = 0.4 + if sys.version_info >= (3, 11): + expected_peak_mem = 4.0 + expected_init_duration = 0.4 + if sys.version_info >= (3, 12): + expected_peak_mem = 3.8 + expected_init_duration = 0.4 + if sys.version_info >= (3, 13): + expected_peak_mem = 4.0 + expected_init_duration = 0.4 + + assert init_without_profiling_stop_timestamp < init_without_profiling_expected,\ + f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' + assert stats.total_tt < expected_init_duration,\ + f"Execution time too high: {stats.total_tt:.3f} sec" + assert peak_mem < expected_peak_mem,\ + f"Peak memory usage too high: {peak_mem:.2f} MB" + assert len(section.sub_elements) == section_sub_elements_count, \ + f"Expected {section_sub_elements_count} elements, got {len(section.sub_elements)}" + assert cpu_time < expected_init_duration, f"CPU execution time too high: {cpu_time:.3f} sec" + + +@pytest.fixture(scope='module') +def set_groups_class_var_objects(): + for _i in range(20): + _element = Element(f'AnotherSection_another_some_element_{_i}') + setattr(AnotherSection, _element.name, _element) + + for _i in range(50): + _element = Element(f'SomeSection_some_element_{_i}') + setattr(SomeSection, _element.name, _element) + + for _i in range(50): + _section = SomeSection(f'{_i}_SomeSection') + setattr(SomePage, _section.name, _section) + + +@pytest.mark.parametrize('case', range(5)) +def test_performance_group_initialisation(mocked_selenium_driver, case, set_groups_class_var_objects): + + tracemalloc.start() + start_cpu = time.process_time() + + with cProfile.Profile() as pr: + page = SomePage() + + end_cpu = time.process_time() + cpu_time = end_cpu - start_cpu # CPU time used + + peak_mem = tracemalloc.get_traced_memory()[1] / 1024**2 + tracemalloc.stop() + + stats: pstats.Stats = pstats.Stats(pr) + stats.strip_dirs().sort_stats("time").print_stats(200) + + count = len(get_all_sub_elements(page)) + init_without_profiling_start_timestamp = time.time() + SomePage() + init_without_profiling_stop_timestamp = time.time() - init_without_profiling_start_timestamp + + print('stats.total_tt=', stats.total_tt) + print('peak_mem=', peak_mem) + print('cpu_time=', cpu_time) + print('init_without_profiling_stop_timestamp=', init_without_profiling_stop_timestamp) + + expected_peak_mem = 3.2 + expected_init_duration = 0.4 + + if sys.version_info >= (3, 9): + expected_peak_mem = 3.5 + expected_init_duration = 0.4 + + if sys.version_info >= (3, 10): + expected_peak_mem = 3.3 + expected_init_duration = 0.4 + + if sys.version_info >= (3, 11): + expected_peak_mem = 2.6 + expected_init_duration = 0.4 + + if sys.version_info >= (3, 12): + expected_peak_mem = 2.5 + expected_init_duration = 0.4 + if sys.version_info >= (3, 13): + expected_peak_mem = 2.8 + expected_init_duration = 0.4 + + assert init_without_profiling_stop_timestamp < 0.15,\ + f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' + assert stats.total_tt < expected_init_duration, \ + f"Execution time too high: {stats.total_tt:.3f} sec" + assert peak_mem < expected_peak_mem, \ + f"Peak memory usage too high: {peak_mem:.2f} MB" + assert cpu_time < expected_init_duration, f"CPU execution time too high: {cpu_time:.3f} sec" + assert count > 3600, f"Expected 3600 elements, got {count}" \ No newline at end of file diff --git a/tests/static_tests/unit/test_get_platform_locator.py b/tests/static_tests/unit/test_get_platform_locator.py index 48748d0d..65c5a68d 100644 --- a/tests/static_tests/unit/test_get_platform_locator.py +++ b/tests/static_tests/unit/test_get_platform_locator.py @@ -21,7 +21,7 @@ @pytest.mark.parametrize('locator', ['tablet', 'android', 'ios', 'mobile', 'desktop']) def test_missed_platform_locator(locator): locator = {locator: None} - element_obj = SimpleNamespace(driver_wrapper=driver_wrapper_mock, locator=Locator(**locator)) + element_obj = SimpleNamespace(driver_wrapper=driver_wrapper_mock, _locator=Locator(**locator)) try: get_platform_locator(element_obj) except InvalidLocatorException as exc: diff --git a/tests/static_tests/unit/test_selector_synchronizer.py b/tests/static_tests/unit/test_selector_synchronizer.py index 0fc2eb22..4b7bb52f 100644 --- a/tests/static_tests/unit/test_selector_synchronizer.py +++ b/tests/static_tests/unit/test_selector_synchronizer.py @@ -26,11 +26,11 @@ ) def test_set_selenium_selector(locator_input, expected_locator, expected_locator_type, expected_log_locator, method): mock_obj = SimpleNamespace() - mock_obj.locator = locator_input + mock_obj._locator = locator_input method(mock_obj) - assert expected_locator == mock_obj.locator - assert expected_locator_type == mock_obj.locator_type - assert expected_log_locator == mock_obj.log_locator + assert expected_locator == mock_obj._locator + assert expected_locator_type == mock_obj._locator_type + assert expected_log_locator == mock_obj._log_locator @pytest.mark.parametrize( @@ -50,11 +50,11 @@ def test_set_selenium_selector(locator_input, expected_locator, expected_locator ) def test_set_playwright_locator(locator_input, expected_locator): mock_obj = SimpleNamespace() - mock_obj.locator = locator_input + mock_obj._locator = locator_input set_playwright_locator(mock_obj) - assert expected_locator == mock_obj.locator - assert expected_locator == mock_obj.log_locator - assert expected_locator.partition('=')[0] == mock_obj.locator_type + assert expected_locator == mock_obj._locator + assert expected_locator == mock_obj._log_locator + assert expected_locator.partition('=')[0] == mock_obj._locator_type com_android_locator = 'com.android.settings:id/title' @@ -79,11 +79,11 @@ def test_set_playwright_locator(locator_input, expected_locator): def test_set_appium_native_selector(locator, locator_type): mock_obj = SimpleNamespace() log_locator = f'{locator_type}={locator}' - mock_obj.locator = log_locator + mock_obj._locator = log_locator set_appium_selector(mock_obj) - assert locator == mock_obj.locator - assert locator_type == mock_obj.locator_type - assert log_locator == mock_obj.log_locator + assert locator == mock_obj._locator + assert locator_type == mock_obj._locator_type + assert log_locator == mock_obj._log_locator @pytest.mark.parametrize( @@ -95,10 +95,10 @@ def test_set_appium_native_selector(locator, locator_type): ) def test_set_automatically_appium_selector(locator, source_locator_type, expected_locator, expected_log_locator): mock_obj = SimpleNamespace() - mock_obj.locator = locator + mock_obj._locator = locator set_appium_selector(mock_obj) - assert expected_locator == mock_obj.locator - assert source_locator_type == mock_obj.locator_type - assert expected_log_locator == mock_obj.log_locator + assert expected_locator == mock_obj._locator + assert source_locator_type == mock_obj._locator_type + assert expected_log_locator == mock_obj._log_locator diff --git a/tests/web_tests/test_assert_screenshot.py b/tests/web_tests/test_assert_screenshot.py index 52d56a2c..717144da 100644 --- a/tests/web_tests/test_assert_screenshot.py +++ b/tests/web_tests/test_assert_screenshot.py @@ -189,4 +189,5 @@ def test_assert_screenshot_hide_driver_elements(colored_blocks_page, driver_wrap hide=[all_cards[0], all_cards[2]] + colored_blocks_page.navbar.all_elements, name_suffix='sides hidden', delay=0.5, + threshold=0.2 ) diff --git a/tests/web_tests/test_element.py b/tests/web_tests/test_element.py index 20b1023c..67db9481 100644 --- a/tests/web_tests/test_element.py +++ b/tests/web_tests/test_element.py @@ -99,6 +99,15 @@ def test_element_group_all_elements_child(second_playground_page): assert KeyboardPage().wait_page_loaded().is_page_opened() +def test_all_elements_inside_all_elements(second_playground_page): + """ all_elements when parent of Element is Group """ + all_cards = second_playground_page.get_all_cards() + for card in all_cards: + for button in card.any_button.all_elements: + assert button.parent is not None + assert card.button.get_elements_count() == 1 + + def test_all_elements_recursion(base_playground_page): try: base_playground_page.kube.all_elements[0].all_elements diff --git a/tests/web_tests/test_wait.py b/tests/web_tests/test_wait.py index 4b3b58be..845be0af 100644 --- a/tests/web_tests/test_wait.py +++ b/tests/web_tests/test_wait.py @@ -106,7 +106,7 @@ def test_wait_continuous_hidden_negative(expected_condition_page, caplog): try: expected_condition_page.blinking_card.blinking_panel.wait_hidden(continuous=True) except ContinuousWaitException as exc: - assert 'The continuous "wait_hidden" of the "blinking panel" is no met after 0.' in exc.msg + assert 'The continuous "wait_hidden" of the "blinking panel" is not met after 0.' in exc.msg else: raise Exception('Unexpected behaviour. Case not covered') @@ -116,6 +116,6 @@ def test_wait_continuous_visibility_negative(expected_condition_page, caplog): try: expected_condition_page.blinking_card.blinking_panel.wait_visibility(continuous=True) except ContinuousWaitException as exc: - assert 'The continuous "wait_visibility" of the "blinking panel" is no met after 0.' in exc.msg + assert 'The continuous "wait_visibility" of the "blinking panel" is not met after 0.' in exc.msg else: raise Exception('Unexpected behaviour. Case not covered') diff --git a/tests/web_tests/test_wait_wihtout_error.py b/tests/web_tests/test_wait_wihtout_error.py index a125316b..0bc1bc2e 100644 --- a/tests/web_tests/test_wait_wihtout_error.py +++ b/tests/web_tests/test_wait_wihtout_error.py @@ -53,12 +53,12 @@ def test_wait_continuous_visibility_without_error_positive(expected_condition_pa def test_wait_continuous_hidden_without_error_negative(expected_condition_page, caplog): expected_condition_page.blinking_card.set_interval() expected_condition_page.blinking_card.blinking_panel.wait_hidden_without_error(continuous=True) - assert 'The continuous "wait_hidden" of the "blinking panel" is no met after 0.' in str(caplog.messages) + assert 'The continuous "wait_hidden" of the "blinking panel" is not met after 0.' in str(caplog.messages) assert expected_condition_page.blinking_card.blinking_panel.is_displayed() def test_wait_continuous_visibility_without_error_negative(expected_condition_page, caplog): expected_condition_page.blinking_card.set_interval() expected_condition_page.blinking_card.blinking_panel.wait_visibility_without_error(continuous=True) - assert 'The continuous "wait_visibility" of the "blinking panel" is no met after 0.' in str(caplog.messages) + assert 'The continuous "wait_visibility" of the "blinking panel" is not met after 0.' in str(caplog.messages) assert expected_condition_page.blinking_card.blinking_panel.is_hidden()