From 05dbd86bd2ba0114a3eac482810fe44638b95302 Mon Sep 17 00:00:00 2001 From: wwakabobik Date: Mon, 23 Mar 2026 17:35:27 +0100 Subject: [PATCH 1/2] Add Element.source_locator attribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: During element initialization, the `locator` attribute is overwritten by platform-specific transformations (e.g. an XPath "//div[@id='x']" becomes "xpath=//div[@id='x']" in Playwright, or gets converted to a CSS selector "[id='x']" in Selenium for ID-based locators). When using a `Locator` dataclass, the multi-platform object is resolved to a single string. After init, there is no way to access the original locator value. This forces downstream projects to manually save it before calling super().__init__(), e.g.: self.base_locator = locator super().__init__(locator, name, ...) This pattern is needed whenever child locators are built dynamically: self.input = Element(f'{self.base_locator}//input', name='Input') We encountered this in a production test suite where every Page, Group, and Element subclass required this workaround. Solution: Add `source_locator` — set once in Element.__init__ before any transformation runs. It preserves the exact type and value passed in (str or Locator dataclass). Purely additive, no existing behavior changes. Includes: - source_locator attribute with Sphinx-compatible docstring - Key Features documentation section with usage example - Integration tests for string, Locator dataclass, and Group children - CHANGELOG entry Made-with: Cursor --- CHANGELOG.md | 7 ++ docs/source/element_object/key_features.md | 53 ++++++++++++ mops/base/element.py | 6 ++ .../integration/test_source_locator.py | 83 +++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 tests/static_tests/integration/test_source_locator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 60342d5e..d6460637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@
+## v3.4.0 + +### 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..1aa8f750 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** child 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/base/element.py b/mops/base/element.py index 7ecf711b..cabe9c31 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -55,6 +55,11 @@ class Element(DriverMixin, InternalMixin, Logging, ElementABC): and provides a unified interface for UI interactions. """ + #: The original locator as provided to ``__init__``, before any platform resolution + #: or framework-specific transformations. Useful for building child locators from + #: the original value. + source_locator: Union[Locator, str] + _object = 'element' _base_cls: Type[PlayElement, MobileElement, WebElement] driver_wrapper: DriverWrapper @@ -115,6 +120,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..b68c95e9 --- /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') + + child_element = Element(css_locator, name='child 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_group_children(driver, request): + request.getfixturevalue(driver) + group = SourceLocatorGroup() + assert group.source_locator == xpath_locator + assert group.child_element.source_locator == css_locator + assert group.multi_element.source_locator is multi_platform_locator From bf2b8bd3f2701464e3819f5163b122c4ba4847a6 Mon Sep 17 00:00:00 2001 From: Vladimir Podolyan Date: Tue, 24 Mar 2026 12:05:28 +0100 Subject: [PATCH 2/2] Version and spelling update --- CHANGELOG.md | 3 ++- docs/source/element_object/key_features.md | 2 +- mops/__init__.py | 2 +- mops/base/element.py | 3 --- tests/static_tests/integration/test_source_locator.py | 6 +++--- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6460637..8f599da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@
-## v3.4.0 +## v3.3.2 +*Release date: 2026-03-24* ### Added - `Element.source_locator` attribute that preserves the original locator before platform-specific transformations diff --git a/docs/source/element_object/key_features.md b/docs/source/element_object/key_features.md index 1aa8f750..9503e0ce 100644 --- a/docs/source/element_object/key_features.md +++ b/docs/source/element_object/key_features.md @@ -50,7 +50,7 @@ The `source_locator` attribute stores the original locator exactly as it was pro before any platform-specific resolution or framework-specific transformations. ```{note} -For **static** child elements, consider using the built-in parent mechanism instead — +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>`). 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 cabe9c31..82ddbaa6 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -55,9 +55,6 @@ class Element(DriverMixin, InternalMixin, Logging, ElementABC): and provides a unified interface for UI interactions. """ - #: The original locator as provided to ``__init__``, before any platform resolution - #: or framework-specific transformations. Useful for building child locators from - #: the original value. source_locator: Union[Locator, str] _object = 'element' diff --git a/tests/static_tests/integration/test_source_locator.py b/tests/static_tests/integration/test_source_locator.py index b68c95e9..9e518238 100644 --- a/tests/static_tests/integration/test_source_locator.py +++ b/tests/static_tests/integration/test_source_locator.py @@ -26,7 +26,7 @@ class SourceLocatorGroup(Group): def __init__(self): super().__init__(xpath_locator, name='source locator group') - child_element = Element(css_locator, name='child element') + sub_element = Element(css_locator, name='sub element') multi_element = Element(multi_platform_locator, name='multi element') @@ -75,9 +75,9 @@ def test_source_locator_preserved_for_locator_dataclass_mobile(driver, request): @pytest.mark.parametrize('driver', desktop_drivers, ids=desktop_ids) -def test_source_locator_preserved_in_group_children(driver, request): +def test_source_locator_preserved_in_sub_elements(driver, request): request.getfixturevalue(driver) group = SourceLocatorGroup() assert group.source_locator == xpath_locator - assert group.child_element.source_locator == css_locator + assert group.sub_element.source_locator == css_locator assert group.multi_element.source_locator is multi_platform_locator