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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

<br>

## 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*

Expand Down
53 changes: 53 additions & 0 deletions docs/source/element_object/key_features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

<br>


## 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).
```
2 changes: 1 addition & 1 deletion mops/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = '3.3.1'
__version__ = '3.3.2'
__project_name__ = 'mops'
3 changes: 3 additions & 0 deletions mops/base/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions tests/static_tests/integration/test_source_locator.py
Original file line number Diff line number Diff line change
@@ -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
Loading