diff --git a/CHANGELOG.md b/CHANGELOG.md index 60342d5e..8f599da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@
+## v3.3.2 +*Release date: 2026-03-24* + +### Added +- `Element.source_locator` attribute that preserves the original locator before platform-specific transformations + +--- + ## v3.3.1 *Release date: 2026-01-05* diff --git a/docs/source/element_object/key_features.md b/docs/source/element_object/key_features.md index fe34abe8..9503e0ce 100644 --- a/docs/source/element_object/key_features.md +++ b/docs/source/element_object/key_features.md @@ -39,3 +39,56 @@ particularly for negative checks (i.e., when an element is not present on the pa ## 3. Built-in waits Most methods automatically wait for specific element states. For example, the framework will wait until a web element becomes clickable before executing `click` method on it. + +--- + +
+ + +## 4. Original locator preservation +The `source_locator` attribute stores the original locator exactly as it was provided to `Element.__init__`, +before any platform-specific resolution or framework-specific transformations. + +```{note} +For **static** sub-elements, consider using the built-in parent mechanism instead — +`Element` objects defined as class attributes of a `Group` automatically search within +the Group locator (see {doc}`Group documentation <../group_object/index>`). + +`source_locator` is designed for cases where you need the **raw locator string** for +dynamic XPath construction at runtime — something the parent mechanism cannot do. +``` + +**Example — dynamic table parsing:** + +```python +from mops.base.group import Group +from mops.base.element import Element + + +class DataTable(Group): + + def load(self): + row_locator = f'{self.source_locator}//tr' + row_elements = Element(row_locator, f'{self.name}: Rows').all_elements + + self.rows = [] + for index, _ in enumerate(row_elements): + cell_locator = f'({row_locator})[{index + 1}]/td' + cells = Element(cell_locator, f'{self.name}: Row {index} cells') + self.rows.append(cells) +``` + +Here `source_locator` is used to dynamically compose new XPath expressions +via string concatenation. This cannot be achieved with the parent mechanism because: + +- The XPath grouping operator `(…)[n]` requires building the full expression as a single string. +- New `Element` objects are created at runtime, not as class-level attributes. +- After initialization, `locator` is transformed with platform prefixes + (e.g., `xpath=` for Playwright), making it unsuitable for string concatenation. + +```{note} +`source_locator` preserves the exact type passed in: if a `Locator` dataclass was given, it stays a `Locator`; +if a string was given, it stays the original string. +The `locator` attribute, by contrast, is resolved to a platform-specific string and may be further modified +(e.g., prefixed with `xpath=` for Playwright or converted to a CSS selector for ID-based locators in Selenium). +``` diff --git a/mops/__init__.py b/mops/__init__.py index e2527452..9553764d 100644 --- a/mops/__init__.py +++ b/mops/__init__.py @@ -1,2 +1,2 @@ -__version__ = '3.3.1' +__version__ = '3.3.2' __project_name__ = 'mops' diff --git a/mops/base/element.py b/mops/base/element.py index 7ecf711b..82ddbaa6 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -55,6 +55,8 @@ class Element(DriverMixin, InternalMixin, Logging, ElementABC): and provides a unified interface for UI interactions. """ + source_locator: Union[Locator, str] + _object = 'element' _base_cls: Type[PlayElement, MobileElement, WebElement] driver_wrapper: DriverWrapper @@ -115,6 +117,7 @@ def __init__( raise ValueError(error) self.locator = locator + self.source_locator = locator self.name = name if name else locator self.parent = parent self.wait = wait diff --git a/tests/static_tests/integration/test_source_locator.py b/tests/static_tests/integration/test_source_locator.py new file mode 100644 index 00000000..9e518238 --- /dev/null +++ b/tests/static_tests/integration/test_source_locator.py @@ -0,0 +1,83 @@ +import pytest + +from mops.base.element import Element +from mops.base.group import Group +from mops.mixins.objects.locator import Locator +from tests.static_tests.conftest import desktop_drivers, desktop_ids, mobile_drivers, mobile_ids + + +xpath_locator = '//div[@class="test"]' +css_locator = '.test-class' +id_locator = 'test-id' +ios_locator = 'ios_locator' +android_locator = 'android_locator' +mobile_locator = 'mobile_locator' + +multi_platform_locator = Locator( + default='default_locator', + desktop='desktop_locator', + mobile=mobile_locator, + ios=ios_locator, + android=android_locator, +) + + +class SourceLocatorGroup(Group): + def __init__(self): + super().__init__(xpath_locator, name='source locator group') + + sub_element = Element(css_locator, name='sub element') + multi_element = Element(multi_platform_locator, name='multi element') + + +@pytest.mark.parametrize('driver', desktop_drivers, ids=desktop_ids) +def test_source_locator_preserved_for_string_xpath(driver, request): + request.getfixturevalue(driver) + element = Element(xpath_locator, name='xpath element') + assert element.source_locator == xpath_locator + + +@pytest.mark.parametrize('driver', desktop_drivers, ids=desktop_ids) +def test_source_locator_preserved_for_string_css(driver, request): + request.getfixturevalue(driver) + element = Element(css_locator, name='css element') + assert element.source_locator == css_locator + + +@pytest.mark.parametrize('driver', desktop_drivers, ids=desktop_ids) +def test_source_locator_preserved_for_string_id(driver, request): + request.getfixturevalue(driver) + element = Element(id_locator, name='id element') + assert element.source_locator == id_locator + + +@pytest.mark.parametrize('driver', desktop_drivers, ids=desktop_ids) +def test_source_locator_differs_from_transformed_locator(driver, request): + request.getfixturevalue(driver) + element = Element(xpath_locator, name='xpath element') + assert element.source_locator == xpath_locator + assert element.locator != xpath_locator or element.source_locator == element.locator + + +@pytest.mark.parametrize('driver', desktop_drivers, ids=desktop_ids) +def test_source_locator_preserved_for_locator_dataclass(driver, request): + request.getfixturevalue(driver) + element = Element(multi_platform_locator, name='multi element') + assert element.source_locator is multi_platform_locator + + +@pytest.mark.parametrize('driver', mobile_drivers, ids=mobile_ids) +def test_source_locator_preserved_for_locator_dataclass_mobile(driver, request): + request.getfixturevalue(driver) + element = Element(multi_platform_locator, name='multi element') + assert element.source_locator is multi_platform_locator + assert isinstance(element.locator, str) + + +@pytest.mark.parametrize('driver', desktop_drivers, ids=desktop_ids) +def test_source_locator_preserved_in_sub_elements(driver, request): + request.getfixturevalue(driver) + group = SourceLocatorGroup() + assert group.source_locator == xpath_locator + assert group.sub_element.source_locator == css_locator + assert group.multi_element.source_locator is multi_platform_locator