crosslocator 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crosslocator-0.2.0/.gitignore +28 -0
- crosslocator-0.2.0/LICENSE +21 -0
- crosslocator-0.2.0/PKG-INFO +152 -0
- crosslocator-0.2.0/README.md +125 -0
- crosslocator-0.2.0/examples/README.md +27 -0
- crosslocator-0.2.0/examples/python/demo.py +30 -0
- crosslocator-0.2.0/examples/python/locators.py +25 -0
- crosslocator-0.2.0/examples/robot/locators.py +22 -0
- crosslocator-0.2.0/examples/robot/login.robot +21 -0
- crosslocator-0.2.0/pyproject.toml +53 -0
- crosslocator-0.2.0/src/crosslocator/__init__.py +29 -0
- crosslocator-0.2.0/src/crosslocator/appium.py +55 -0
- crosslocator-0.2.0/src/crosslocator/locator.py +107 -0
- crosslocator-0.2.0/src/crosslocator/platform.py +89 -0
- crosslocator-0.2.0/src/crosslocator/py.typed +0 -0
- crosslocator-0.2.0/src/crosslocator/robot.py +51 -0
- crosslocator-0.2.0/tests/test_appium.py +49 -0
- crosslocator-0.2.0/tests/test_locator.py +56 -0
- crosslocator-0.2.0/tests/test_platform.py +40 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
env/
|
|
11
|
+
|
|
12
|
+
# Tooling
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# Editors / OS
|
|
20
|
+
.vscode/
|
|
21
|
+
.idea/
|
|
22
|
+
.DS_Store
|
|
23
|
+
Thumbs.db
|
|
24
|
+
|
|
25
|
+
# Robot Framework outputs
|
|
26
|
+
output.xml
|
|
27
|
+
log.html
|
|
28
|
+
report.html
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julien Becheny
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crosslocator
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Define a UI selector once, resolve it on every platform (Android, iOS, iPadOS, Windows, web).
|
|
5
|
+
Project-URL: Homepage, https://github.com/julien-becheny/crosslocator
|
|
6
|
+
Project-URL: Repository, https://github.com/julien-becheny/crosslocator
|
|
7
|
+
Project-URL: Issues, https://github.com/julien-becheny/crosslocator/issues
|
|
8
|
+
Author: Julien Becheny
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: android,appium,ios,locators,mobile-testing,playwright,robotframework,selectors,test-automation,windows
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Robot Framework :: Library
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# crosslocator
|
|
29
|
+
|
|
30
|
+
**Define a UI selector once. Resolve it on every platform.**
|
|
31
|
+
|
|
32
|
+
Cross-platform test automation means maintaining the same element for Android,
|
|
33
|
+
iOS, iPadOS, Windows and the web. Most teams end up either duplicating selectors
|
|
34
|
+
or scattering `if platform == ...` branches through their page objects.
|
|
35
|
+
|
|
36
|
+
`crosslocator` lets you declare a selector **once**, per platform, and resolves
|
|
37
|
+
the right one at runtime — from **pure Python (Appium), Robot Framework, or any
|
|
38
|
+
other framework**. It only routes selector *strings*; it never wraps your driver.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from crosslocator import Locator
|
|
42
|
+
|
|
43
|
+
ADD_PATIENT = Locator(
|
|
44
|
+
android="accessibility_id=add_patient",
|
|
45
|
+
ios="add_patient",
|
|
46
|
+
windows='//Button[@Name="Add"]',
|
|
47
|
+
default="id=add",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
ADD_PATIENT.for_platform("ios") # -> "add_patient"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install crosslocator
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Zero runtime dependencies.
|
|
60
|
+
|
|
61
|
+
## Why
|
|
62
|
+
|
|
63
|
+
- **One source of truth** per element — no duplicated selectors, no platform `if/else`.
|
|
64
|
+
- **Framework-agnostic** — Appium in plain Python, Robot Framework, or anything else.
|
|
65
|
+
- **Sensible fallbacks** — iPad inherits from iOS, and a shared `default` covers the rest.
|
|
66
|
+
- **Typed** and dependency-free.
|
|
67
|
+
|
|
68
|
+
## Quick start (Python)
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from crosslocator import Locator, set_current_platform
|
|
72
|
+
|
|
73
|
+
ADD_PATIENT = Locator(
|
|
74
|
+
android="accessibility_id=add_patient",
|
|
75
|
+
ios="add_patient",
|
|
76
|
+
windows='//Button[@Name="Add"]',
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
set_current_platform("android") # usually done once, at session start
|
|
80
|
+
ADD_PATIENT.resolve() # -> "accessibility_id=add_patient"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Robot Framework
|
|
84
|
+
|
|
85
|
+
```robotframework
|
|
86
|
+
*** Settings ***
|
|
87
|
+
Library crosslocator.robot.CrossLocator
|
|
88
|
+
Variables locators.py # ADD_PATIENT defined as a Locator here
|
|
89
|
+
|
|
90
|
+
*** Test Cases ***
|
|
91
|
+
Open Add Patient
|
|
92
|
+
Set Current Platform android
|
|
93
|
+
${selector}= Resolve Locator ${ADD_PATIENT}
|
|
94
|
+
# -> pass ${selector} to AppiumLibrary / Browser as usual
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## How the platform is resolved
|
|
98
|
+
|
|
99
|
+
`resolve()` determines the current platform in this order:
|
|
100
|
+
|
|
101
|
+
1. an explicit `set_current_platform(...)`
|
|
102
|
+
2. the `CROSSLOCATOR_PLATFORM` environment variable
|
|
103
|
+
3. a registered provider — typically an Appium session (see below)
|
|
104
|
+
|
|
105
|
+
## Automatic platform detection (Appium)
|
|
106
|
+
|
|
107
|
+
Let crosslocator read the platform straight from a live Appium session, so you
|
|
108
|
+
never call `set_current_platform` by hand.
|
|
109
|
+
|
|
110
|
+
**Python (Appium-Python-Client):**
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from crosslocator import use_appium
|
|
114
|
+
|
|
115
|
+
use_appium(driver) # your Appium webdriver
|
|
116
|
+
LOGIN_BUTTON.resolve() # platform detected from the session capabilities
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Robot Framework (AppiumLibrary):**
|
|
120
|
+
|
|
121
|
+
```robotframework
|
|
122
|
+
Open Application ${REMOTE_URL} platformName=Android ...
|
|
123
|
+
Use Appium Session
|
|
124
|
+
${selector}= Resolve Locator ${LOGIN_BUTTON}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
iPad is detected from the session device name and resolves to iPad selectors
|
|
128
|
+
(falling back to iOS when none are defined).
|
|
129
|
+
|
|
130
|
+
## Fallback rules
|
|
131
|
+
|
|
132
|
+
- `ipad` → falls back to `ios` when no iPad-specific selector is set
|
|
133
|
+
- any platform → falls back to `default`
|
|
134
|
+
- nothing found → `LocatorNotFound`
|
|
135
|
+
|
|
136
|
+
## Examples
|
|
137
|
+
|
|
138
|
+
Runnable examples (no device needed) live in the [`examples/`](examples/) folder —
|
|
139
|
+
a login screen resolved across Android, iOS, iPadOS and Windows, plus a Robot
|
|
140
|
+
Framework suite.
|
|
141
|
+
|
|
142
|
+
## Roadmap
|
|
143
|
+
|
|
144
|
+
- **v0.1:** the core `Locator` type, platform resolution, Robot Framework keywords.
|
|
145
|
+
- **v0.2 (current):** automatic platform detection from a live Appium session.
|
|
146
|
+
- **Next:** Playwright/Browser helpers, and declarative loading of whole screens from YAML/dict.
|
|
147
|
+
|
|
148
|
+
Issues and contributions are welcome.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT © 2026 Julien Becheny
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# crosslocator
|
|
2
|
+
|
|
3
|
+
**Define a UI selector once. Resolve it on every platform.**
|
|
4
|
+
|
|
5
|
+
Cross-platform test automation means maintaining the same element for Android,
|
|
6
|
+
iOS, iPadOS, Windows and the web. Most teams end up either duplicating selectors
|
|
7
|
+
or scattering `if platform == ...` branches through their page objects.
|
|
8
|
+
|
|
9
|
+
`crosslocator` lets you declare a selector **once**, per platform, and resolves
|
|
10
|
+
the right one at runtime — from **pure Python (Appium), Robot Framework, or any
|
|
11
|
+
other framework**. It only routes selector *strings*; it never wraps your driver.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from crosslocator import Locator
|
|
15
|
+
|
|
16
|
+
ADD_PATIENT = Locator(
|
|
17
|
+
android="accessibility_id=add_patient",
|
|
18
|
+
ios="add_patient",
|
|
19
|
+
windows='//Button[@Name="Add"]',
|
|
20
|
+
default="id=add",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
ADD_PATIENT.for_platform("ios") # -> "add_patient"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install crosslocator
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Zero runtime dependencies.
|
|
33
|
+
|
|
34
|
+
## Why
|
|
35
|
+
|
|
36
|
+
- **One source of truth** per element — no duplicated selectors, no platform `if/else`.
|
|
37
|
+
- **Framework-agnostic** — Appium in plain Python, Robot Framework, or anything else.
|
|
38
|
+
- **Sensible fallbacks** — iPad inherits from iOS, and a shared `default` covers the rest.
|
|
39
|
+
- **Typed** and dependency-free.
|
|
40
|
+
|
|
41
|
+
## Quick start (Python)
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from crosslocator import Locator, set_current_platform
|
|
45
|
+
|
|
46
|
+
ADD_PATIENT = Locator(
|
|
47
|
+
android="accessibility_id=add_patient",
|
|
48
|
+
ios="add_patient",
|
|
49
|
+
windows='//Button[@Name="Add"]',
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
set_current_platform("android") # usually done once, at session start
|
|
53
|
+
ADD_PATIENT.resolve() # -> "accessibility_id=add_patient"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Robot Framework
|
|
57
|
+
|
|
58
|
+
```robotframework
|
|
59
|
+
*** Settings ***
|
|
60
|
+
Library crosslocator.robot.CrossLocator
|
|
61
|
+
Variables locators.py # ADD_PATIENT defined as a Locator here
|
|
62
|
+
|
|
63
|
+
*** Test Cases ***
|
|
64
|
+
Open Add Patient
|
|
65
|
+
Set Current Platform android
|
|
66
|
+
${selector}= Resolve Locator ${ADD_PATIENT}
|
|
67
|
+
# -> pass ${selector} to AppiumLibrary / Browser as usual
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## How the platform is resolved
|
|
71
|
+
|
|
72
|
+
`resolve()` determines the current platform in this order:
|
|
73
|
+
|
|
74
|
+
1. an explicit `set_current_platform(...)`
|
|
75
|
+
2. the `CROSSLOCATOR_PLATFORM` environment variable
|
|
76
|
+
3. a registered provider — typically an Appium session (see below)
|
|
77
|
+
|
|
78
|
+
## Automatic platform detection (Appium)
|
|
79
|
+
|
|
80
|
+
Let crosslocator read the platform straight from a live Appium session, so you
|
|
81
|
+
never call `set_current_platform` by hand.
|
|
82
|
+
|
|
83
|
+
**Python (Appium-Python-Client):**
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from crosslocator import use_appium
|
|
87
|
+
|
|
88
|
+
use_appium(driver) # your Appium webdriver
|
|
89
|
+
LOGIN_BUTTON.resolve() # platform detected from the session capabilities
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Robot Framework (AppiumLibrary):**
|
|
93
|
+
|
|
94
|
+
```robotframework
|
|
95
|
+
Open Application ${REMOTE_URL} platformName=Android ...
|
|
96
|
+
Use Appium Session
|
|
97
|
+
${selector}= Resolve Locator ${LOGIN_BUTTON}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
iPad is detected from the session device name and resolves to iPad selectors
|
|
101
|
+
(falling back to iOS when none are defined).
|
|
102
|
+
|
|
103
|
+
## Fallback rules
|
|
104
|
+
|
|
105
|
+
- `ipad` → falls back to `ios` when no iPad-specific selector is set
|
|
106
|
+
- any platform → falls back to `default`
|
|
107
|
+
- nothing found → `LocatorNotFound`
|
|
108
|
+
|
|
109
|
+
## Examples
|
|
110
|
+
|
|
111
|
+
Runnable examples (no device needed) live in the [`examples/`](examples/) folder —
|
|
112
|
+
a login screen resolved across Android, iOS, iPadOS and Windows, plus a Robot
|
|
113
|
+
Framework suite.
|
|
114
|
+
|
|
115
|
+
## Roadmap
|
|
116
|
+
|
|
117
|
+
- **v0.1:** the core `Locator` type, platform resolution, Robot Framework keywords.
|
|
118
|
+
- **v0.2 (current):** automatic platform detection from a live Appium session.
|
|
119
|
+
- **Next:** Playwright/Browser helpers, and declarative loading of whole screens from YAML/dict.
|
|
120
|
+
|
|
121
|
+
Issues and contributions are welcome.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT © 2026 Julien Becheny
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
These examples use a generic **login screen** and need no real device. They
|
|
4
|
+
resolve the same locators on several platforms to show what crosslocator does.
|
|
5
|
+
|
|
6
|
+
## Python
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
cd examples/python
|
|
10
|
+
python demo.py
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Prints the same page object resolved for Android, iOS, iPadOS and Windows —
|
|
14
|
+
notice iPad falls back to the iOS selector, and Windows uses the shared default.
|
|
15
|
+
|
|
16
|
+
## Robot Framework
|
|
17
|
+
|
|
18
|
+
Requires Robot Framework:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install robotframework
|
|
22
|
+
robot examples/robot/login.robot
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The suite resolves locators for Android, iOS and iPadOS and asserts the expected
|
|
26
|
+
selector strings — a self-contained check that the Robot Framework integration
|
|
27
|
+
works end to end.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Resolve the same page object on several platforms.
|
|
2
|
+
|
|
3
|
+
Run from this folder::
|
|
4
|
+
|
|
5
|
+
python demo.py
|
|
6
|
+
|
|
7
|
+
Notice that iPad falls back to the iOS selector, and Windows uses the shared
|
|
8
|
+
default when no Windows-specific selector applies.
|
|
9
|
+
"""
|
|
10
|
+
from crosslocator import set_current_platform
|
|
11
|
+
|
|
12
|
+
from locators import LOGIN_BUTTON, PASSWORD_FIELD, USERNAME_FIELD
|
|
13
|
+
|
|
14
|
+
ELEMENTS = {
|
|
15
|
+
"Username field": USERNAME_FIELD,
|
|
16
|
+
"Password field": PASSWORD_FIELD,
|
|
17
|
+
"Login button": LOGIN_BUTTON,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def show_for(platform: str) -> None:
|
|
22
|
+
set_current_platform(platform)
|
|
23
|
+
print(f"\n== {platform} ==")
|
|
24
|
+
for name, locator in ELEMENTS.items():
|
|
25
|
+
print(f" {name:16} -> {locator.resolve()}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
for current in ("android", "ios", "ipad", "windows"):
|
|
30
|
+
show_for(current)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Example page object: a login screen, defined once for every platform.
|
|
2
|
+
|
|
3
|
+
Each element is declared a single time, with its selector per platform. There
|
|
4
|
+
is no duplication and no `if platform == ...` branching in the test code.
|
|
5
|
+
"""
|
|
6
|
+
from crosslocator import Locator
|
|
7
|
+
|
|
8
|
+
USERNAME_FIELD = Locator(
|
|
9
|
+
android="id=com.example:id/username",
|
|
10
|
+
ios="accessibility_id=username",
|
|
11
|
+
windows='//Edit[@AutomationId="username"]',
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
PASSWORD_FIELD = Locator(
|
|
15
|
+
android="id=com.example:id/password",
|
|
16
|
+
ios="accessibility_id=password",
|
|
17
|
+
windows='//Edit[@AutomationId="password"]',
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
LOGIN_BUTTON = Locator(
|
|
21
|
+
android="accessibility_id=login",
|
|
22
|
+
ios="accessibility_id=login",
|
|
23
|
+
windows='//Button[@Name="Log in"]',
|
|
24
|
+
default="id=login",
|
|
25
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Robot Framework variable file: exposes Locator objects as suite variables.
|
|
2
|
+
|
|
3
|
+
Imported from login.robot via `Variables locators.py`. Each name becomes a
|
|
4
|
+
Robot scalar variable, e.g. ${USERNAME_FIELD}.
|
|
5
|
+
"""
|
|
6
|
+
from crosslocator import Locator
|
|
7
|
+
|
|
8
|
+
USERNAME_FIELD = Locator(
|
|
9
|
+
android="id=com.example:id/username",
|
|
10
|
+
ios="accessibility_id=username",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
PASSWORD_FIELD = Locator(
|
|
14
|
+
android="id=com.example:id/password",
|
|
15
|
+
ios="accessibility_id=password",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
LOGIN_BUTTON = Locator(
|
|
19
|
+
android="accessibility_id=login",
|
|
20
|
+
ios="accessibility_id=login",
|
|
21
|
+
default="id=login",
|
|
22
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
*** Settings ***
|
|
2
|
+
Library crosslocator.robot.CrossLocator
|
|
3
|
+
Variables locators.py
|
|
4
|
+
|
|
5
|
+
*** Test Cases ***
|
|
6
|
+
Resolve Login Selectors On Android
|
|
7
|
+
Set Current Platform android
|
|
8
|
+
${username}= Resolve Locator ${USERNAME_FIELD}
|
|
9
|
+
${login}= Resolve Locator ${LOGIN_BUTTON}
|
|
10
|
+
Should Be Equal ${username} id=com.example:id/username
|
|
11
|
+
Should Be Equal ${login} accessibility_id=login
|
|
12
|
+
|
|
13
|
+
Resolve Login Selectors On iOS
|
|
14
|
+
Set Current Platform ios
|
|
15
|
+
${username}= Resolve Locator ${USERNAME_FIELD}
|
|
16
|
+
Should Be Equal ${username} accessibility_id=username
|
|
17
|
+
|
|
18
|
+
iPad Falls Back To The iOS Selector
|
|
19
|
+
Set Current Platform ipad
|
|
20
|
+
${login}= Resolve Locator ${LOGIN_BUTTON}
|
|
21
|
+
Should Be Equal ${login} accessibility_id=login
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crosslocator"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Define a UI selector once, resolve it on every platform (Android, iOS, iPadOS, Windows, web)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Julien Becheny" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"test-automation",
|
|
15
|
+
"appium",
|
|
16
|
+
"robotframework",
|
|
17
|
+
"playwright",
|
|
18
|
+
"mobile-testing",
|
|
19
|
+
"android",
|
|
20
|
+
"ios",
|
|
21
|
+
"windows",
|
|
22
|
+
"locators",
|
|
23
|
+
"selectors",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 3 - Alpha",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.9",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
"Programming Language :: Python :: 3.11",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Topic :: Software Development :: Testing",
|
|
36
|
+
"Framework :: Robot Framework :: Library",
|
|
37
|
+
]
|
|
38
|
+
dependencies = []
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = ["pytest>=7.0"]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/julien-becheny/crosslocator"
|
|
45
|
+
Repository = "https://github.com/julien-becheny/crosslocator"
|
|
46
|
+
Issues = "https://github.com/julien-becheny/crosslocator/issues"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/crosslocator"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
pythonpath = ["src"]
|
|
53
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""crosslocator — define a UI selector once, resolve it on every platform."""
|
|
2
|
+
from .appium import use_appium
|
|
3
|
+
from .locator import (
|
|
4
|
+
CrossLocatorError,
|
|
5
|
+
Locator,
|
|
6
|
+
LocatorNotFound,
|
|
7
|
+
PlatformNotSet,
|
|
8
|
+
)
|
|
9
|
+
from .platform import (
|
|
10
|
+
Platform,
|
|
11
|
+
get_current_platform,
|
|
12
|
+
set_current_platform,
|
|
13
|
+
set_platform_provider,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Locator",
|
|
20
|
+
"Platform",
|
|
21
|
+
"get_current_platform",
|
|
22
|
+
"set_current_platform",
|
|
23
|
+
"set_platform_provider",
|
|
24
|
+
"use_appium",
|
|
25
|
+
"CrossLocatorError",
|
|
26
|
+
"LocatorNotFound",
|
|
27
|
+
"PlatformNotSet",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Automatic platform detection from a live Appium session.
|
|
2
|
+
|
|
3
|
+
crosslocator stays framework-agnostic: this module only reads the session
|
|
4
|
+
capabilities and translates them into a :class:`~crosslocator.platform.Platform`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Mapping, Optional
|
|
9
|
+
|
|
10
|
+
from .platform import Platform, set_platform_provider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def platform_from_capabilities(
|
|
14
|
+
capabilities: Optional[Mapping[str, Any]],
|
|
15
|
+
) -> Optional[Platform]:
|
|
16
|
+
"""Translate Appium capabilities into a :class:`Platform`.
|
|
17
|
+
|
|
18
|
+
Returns ``None`` when the platform cannot be determined.
|
|
19
|
+
"""
|
|
20
|
+
if not capabilities:
|
|
21
|
+
return None
|
|
22
|
+
name = str(
|
|
23
|
+
capabilities.get("platformName")
|
|
24
|
+
or capabilities.get("appium:platformName")
|
|
25
|
+
or ""
|
|
26
|
+
).strip().lower()
|
|
27
|
+
device = str(
|
|
28
|
+
capabilities.get("deviceName")
|
|
29
|
+
or capabilities.get("appium:deviceName")
|
|
30
|
+
or ""
|
|
31
|
+
).lower()
|
|
32
|
+
if name == "android":
|
|
33
|
+
return Platform.ANDROID
|
|
34
|
+
if name in ("ios", "iphoneos", "iphone os"):
|
|
35
|
+
return Platform.IPAD if "ipad" in device else Platform.IOS
|
|
36
|
+
if name in ("windows", "win"):
|
|
37
|
+
return Platform.WINDOWS
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def use_appium(driver: Optional[Any]) -> None:
|
|
42
|
+
"""Detect the current platform automatically from an Appium ``driver``.
|
|
43
|
+
|
|
44
|
+
Registers a provider that reads ``driver.capabilities`` on each resolution,
|
|
45
|
+
so you no longer need to call :func:`set_current_platform` by hand. Pass
|
|
46
|
+
``None`` to stop using the driver.
|
|
47
|
+
"""
|
|
48
|
+
if driver is None:
|
|
49
|
+
set_platform_provider(None)
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
def provider() -> Optional[Platform]:
|
|
53
|
+
return platform_from_capabilities(getattr(driver, "capabilities", None))
|
|
54
|
+
|
|
55
|
+
set_platform_provider(provider)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The :class:`Locator` type: define a selector once, resolve it per platform."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
from .platform import (
|
|
7
|
+
Platform,
|
|
8
|
+
PlatformLike,
|
|
9
|
+
get_current_platform,
|
|
10
|
+
normalize_platform,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CrossLocatorError(Exception):
|
|
15
|
+
"""Base class for all crosslocator errors."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PlatformNotSet(CrossLocatorError):
|
|
19
|
+
"""Raised when a resolution needs the current platform but none is set."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LocatorNotFound(CrossLocatorError, LookupError):
|
|
23
|
+
"""Raised when no selector is available for the requested platform."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# A platform inherits from the next one in its chain when it has no own selector.
|
|
27
|
+
_FALLBACK_CHAINS: Dict[Platform, tuple] = {
|
|
28
|
+
Platform.IPAD: (Platform.IPAD, Platform.IOS),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Locator:
|
|
33
|
+
"""A UI selector declared once for several platforms.
|
|
34
|
+
|
|
35
|
+
Define a selector per platform plus an optional shared ``default``. At
|
|
36
|
+
runtime, :meth:`resolve` (or :meth:`for_platform`) returns the right
|
|
37
|
+
selector string. crosslocator only *routes* strings — it never parses them
|
|
38
|
+
nor touches your driver.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
__slots__ = ("_selectors", "_default")
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
android: Optional[str] = None,
|
|
47
|
+
ios: Optional[str] = None,
|
|
48
|
+
ipad: Optional[str] = None,
|
|
49
|
+
windows: Optional[str] = None,
|
|
50
|
+
web: Optional[str] = None,
|
|
51
|
+
default: Optional[str] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._selectors: Dict[Platform, Optional[str]] = {
|
|
54
|
+
Platform.ANDROID: android,
|
|
55
|
+
Platform.IOS: ios,
|
|
56
|
+
Platform.IPAD: ipad,
|
|
57
|
+
Platform.WINDOWS: windows,
|
|
58
|
+
Platform.WEB: web,
|
|
59
|
+
}
|
|
60
|
+
self._default = default
|
|
61
|
+
if default is None and not any(self._selectors.values()):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"A Locator needs at least one platform selector or a default."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def for_platform(self, platform: PlatformLike) -> str:
|
|
67
|
+
"""Return the selector for ``platform``, applying fallbacks.
|
|
68
|
+
|
|
69
|
+
Resolution order: the platform's own selector, then its fallback chain
|
|
70
|
+
(e.g. iPad -> iOS), then ``default``. Raises :class:`LocatorNotFound`
|
|
71
|
+
if nothing matches.
|
|
72
|
+
"""
|
|
73
|
+
target = normalize_platform(platform)
|
|
74
|
+
for candidate in _FALLBACK_CHAINS.get(target, (target,)):
|
|
75
|
+
value = self._selectors.get(candidate)
|
|
76
|
+
if value is not None:
|
|
77
|
+
return value
|
|
78
|
+
if self._default is not None:
|
|
79
|
+
return self._default
|
|
80
|
+
raise LocatorNotFound(
|
|
81
|
+
f"No selector defined for platform {target.value!r} and no default set."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def resolve(self) -> str:
|
|
85
|
+
"""Return the selector for the current platform.
|
|
86
|
+
|
|
87
|
+
Raises :class:`PlatformNotSet` when the current platform is unknown.
|
|
88
|
+
"""
|
|
89
|
+
platform = get_current_platform()
|
|
90
|
+
if platform is None:
|
|
91
|
+
raise PlatformNotSet(
|
|
92
|
+
"No current platform set. Use set_current_platform(...) or the "
|
|
93
|
+
"CROSSLOCATOR_PLATFORM environment variable."
|
|
94
|
+
)
|
|
95
|
+
return self.for_platform(platform)
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
parts = {p.value: v for p, v in self._selectors.items() if v is not None}
|
|
99
|
+
if self._default is not None:
|
|
100
|
+
parts["default"] = self._default
|
|
101
|
+
inner = ", ".join(f"{k}={v!r}" for k, v in parts.items())
|
|
102
|
+
return f"Locator({inner})"
|
|
103
|
+
|
|
104
|
+
def __eq__(self, other: object) -> bool:
|
|
105
|
+
if not isinstance(other, Locator):
|
|
106
|
+
return NotImplemented
|
|
107
|
+
return self._selectors == other._selectors and self._default == other._default
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Platform identification and current-platform state.
|
|
2
|
+
|
|
3
|
+
crosslocator resolves selectors against a *current platform*. This module owns
|
|
4
|
+
the small enum of supported platforms, alias normalization, and the resolution
|
|
5
|
+
of the current platform from (in priority order):
|
|
6
|
+
|
|
7
|
+
1. an explicit value set via :func:`set_current_platform`
|
|
8
|
+
2. the ``CROSSLOCATOR_PLATFORM`` environment variable
|
|
9
|
+
3. a registered provider (e.g. reading a live Appium session)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Callable, Optional, Union
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Platform(Enum):
|
|
19
|
+
"""A UI platform a selector can target."""
|
|
20
|
+
|
|
21
|
+
ANDROID = "android"
|
|
22
|
+
IOS = "ios"
|
|
23
|
+
IPAD = "ipad"
|
|
24
|
+
WINDOWS = "windows"
|
|
25
|
+
WEB = "web"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PlatformLike = Union[Platform, str]
|
|
29
|
+
|
|
30
|
+
ENV_VAR = "CROSSLOCATOR_PLATFORM"
|
|
31
|
+
|
|
32
|
+
_ALIASES = {
|
|
33
|
+
"android": Platform.ANDROID,
|
|
34
|
+
"ios": Platform.IOS,
|
|
35
|
+
"iphone": Platform.IOS,
|
|
36
|
+
"ipad": Platform.IPAD,
|
|
37
|
+
"ipados": Platform.IPAD,
|
|
38
|
+
"windows": Platform.WINDOWS,
|
|
39
|
+
"win": Platform.WINDOWS,
|
|
40
|
+
"web": Platform.WEB,
|
|
41
|
+
"browser": Platform.WEB,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_current: Optional[Platform] = None
|
|
45
|
+
_provider: Optional[Callable[[], Optional[PlatformLike]]] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_platform(value: PlatformLike) -> Platform:
|
|
49
|
+
"""Return the :class:`Platform` for ``value``, accepting common aliases.
|
|
50
|
+
|
|
51
|
+
Raises :class:`ValueError` for unknown platforms.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(value, Platform):
|
|
54
|
+
return value
|
|
55
|
+
key = str(value).strip().lower()
|
|
56
|
+
try:
|
|
57
|
+
return _ALIASES[key]
|
|
58
|
+
except KeyError:
|
|
59
|
+
raise ValueError(f"Unknown platform: {value!r}") from None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_current_platform(value: Optional[PlatformLike]) -> None:
|
|
63
|
+
"""Set (or clear, with ``None``) the platform used to resolve selectors."""
|
|
64
|
+
global _current
|
|
65
|
+
_current = None if value is None else normalize_platform(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def set_platform_provider(
|
|
69
|
+
provider: Optional[Callable[[], Optional[PlatformLike]]],
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Register a callable returning the current platform (e.g. from Appium).
|
|
72
|
+
|
|
73
|
+
Pass ``None`` to unregister.
|
|
74
|
+
"""
|
|
75
|
+
global _provider
|
|
76
|
+
_provider = provider
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_current_platform() -> Optional[Platform]:
|
|
80
|
+
"""Resolve the current platform, or ``None`` if it cannot be determined."""
|
|
81
|
+
if _current is not None:
|
|
82
|
+
return _current
|
|
83
|
+
env = os.environ.get(ENV_VAR)
|
|
84
|
+
if env:
|
|
85
|
+
return normalize_platform(env)
|
|
86
|
+
if _provider is not None:
|
|
87
|
+
result = _provider()
|
|
88
|
+
return None if result is None else normalize_platform(result)
|
|
89
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Robot Framework integration for crosslocator.
|
|
2
|
+
|
|
3
|
+
Import in a suite with::
|
|
4
|
+
|
|
5
|
+
Library crosslocator.robot.CrossLocator
|
|
6
|
+
|
|
7
|
+
Locators are plain Python objects: define them in a Python variable file and
|
|
8
|
+
import them, then resolve them with the keywords below.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .locator import Locator
|
|
15
|
+
from .platform import get_current_platform, set_current_platform
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CrossLocator:
|
|
19
|
+
"""Robot Framework library exposing crosslocator as keywords."""
|
|
20
|
+
|
|
21
|
+
ROBOT_LIBRARY_SCOPE = "GLOBAL"
|
|
22
|
+
|
|
23
|
+
def set_current_platform(self, platform: str) -> None:
|
|
24
|
+
"""Set the platform used to resolve locators.
|
|
25
|
+
|
|
26
|
+
Accepts ``android``, ``ios``, ``ipad``, ``windows`` or ``web``.
|
|
27
|
+
"""
|
|
28
|
+
set_current_platform(platform)
|
|
29
|
+
|
|
30
|
+
def get_current_platform(self) -> str:
|
|
31
|
+
"""Return the current platform name, or an empty string if unset."""
|
|
32
|
+
platform = get_current_platform()
|
|
33
|
+
return platform.value if platform is not None else ""
|
|
34
|
+
|
|
35
|
+
def resolve_locator(self, locator: Locator, platform: Optional[str] = None) -> str:
|
|
36
|
+
"""Resolve a ``Locator`` to a selector string.
|
|
37
|
+
|
|
38
|
+
Uses ``platform`` when given, otherwise the current platform.
|
|
39
|
+
"""
|
|
40
|
+
if platform:
|
|
41
|
+
return locator.for_platform(platform)
|
|
42
|
+
return locator.resolve()
|
|
43
|
+
|
|
44
|
+
def use_appium_session(self) -> None:
|
|
45
|
+
"""Auto-detect the platform from the active AppiumLibrary session."""
|
|
46
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
47
|
+
|
|
48
|
+
from .appium import use_appium
|
|
49
|
+
|
|
50
|
+
appium_lib = BuiltIn().get_library_instance("AppiumLibrary")
|
|
51
|
+
use_appium(appium_lib._current_application())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from crosslocator import (
|
|
4
|
+
Platform,
|
|
5
|
+
get_current_platform,
|
|
6
|
+
set_current_platform,
|
|
7
|
+
use_appium,
|
|
8
|
+
)
|
|
9
|
+
from crosslocator.appium import platform_from_capabilities
|
|
10
|
+
from crosslocator.platform import set_platform_provider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeDriver:
|
|
14
|
+
def __init__(self, capabilities):
|
|
15
|
+
self.capabilities = capabilities
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize(
|
|
19
|
+
"caps, expected",
|
|
20
|
+
[
|
|
21
|
+
({"platformName": "Android"}, Platform.ANDROID),
|
|
22
|
+
({"platformName": "iOS"}, Platform.IOS),
|
|
23
|
+
({"platformName": "iOS", "deviceName": "iPad Pro"}, Platform.IPAD),
|
|
24
|
+
({"appium:platformName": "Windows"}, Platform.WINDOWS),
|
|
25
|
+
({}, None),
|
|
26
|
+
(None, None),
|
|
27
|
+
({"platformName": "Symbian"}, None),
|
|
28
|
+
],
|
|
29
|
+
)
|
|
30
|
+
def test_platform_from_capabilities(caps, expected):
|
|
31
|
+
assert platform_from_capabilities(caps) is expected
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_use_appium_drives_resolution(monkeypatch):
|
|
35
|
+
monkeypatch.delenv("CROSSLOCATOR_PLATFORM", raising=False)
|
|
36
|
+
set_current_platform(None)
|
|
37
|
+
use_appium(FakeDriver({"platformName": "Android"}))
|
|
38
|
+
try:
|
|
39
|
+
assert get_current_platform() is Platform.ANDROID
|
|
40
|
+
finally:
|
|
41
|
+
set_platform_provider(None)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_use_appium_none_clears_provider(monkeypatch):
|
|
45
|
+
monkeypatch.delenv("CROSSLOCATOR_PLATFORM", raising=False)
|
|
46
|
+
set_current_platform(None)
|
|
47
|
+
use_appium(FakeDriver({"platformName": "iOS"}))
|
|
48
|
+
use_appium(None)
|
|
49
|
+
assert get_current_platform() is None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from crosslocator import Locator, LocatorNotFound, PlatformNotSet, set_current_platform
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_for_platform_returns_specific_selector():
|
|
7
|
+
loc = Locator(android="id=add", ios="add_btn")
|
|
8
|
+
assert loc.for_platform("android") == "id=add"
|
|
9
|
+
assert loc.for_platform("ios") == "add_btn"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_ipad_falls_back_to_ios():
|
|
13
|
+
loc = Locator(ios="add_btn")
|
|
14
|
+
assert loc.for_platform("ipad") == "add_btn"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_ipad_prefers_its_own_selector():
|
|
18
|
+
loc = Locator(ios="add_btn", ipad="ipad_add")
|
|
19
|
+
assert loc.for_platform("ipad") == "ipad_add"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_default_used_when_platform_missing():
|
|
23
|
+
loc = Locator(android="id=add", default="id=fallback")
|
|
24
|
+
assert loc.for_platform("windows") == "id=fallback"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_missing_selector_raises():
|
|
28
|
+
loc = Locator(android="id=add")
|
|
29
|
+
with pytest.raises(LocatorNotFound):
|
|
30
|
+
loc.for_platform("windows")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_resolve_uses_current_platform():
|
|
34
|
+
loc = Locator(android="id=add", ios="add_btn")
|
|
35
|
+
set_current_platform("ios")
|
|
36
|
+
try:
|
|
37
|
+
assert loc.resolve() == "add_btn"
|
|
38
|
+
finally:
|
|
39
|
+
set_current_platform(None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_resolve_without_platform_raises():
|
|
43
|
+
loc = Locator(android="id=add")
|
|
44
|
+
set_current_platform(None)
|
|
45
|
+
with pytest.raises(PlatformNotSet):
|
|
46
|
+
loc.resolve()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_empty_locator_is_rejected():
|
|
50
|
+
with pytest.raises(ValueError):
|
|
51
|
+
Locator()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_repr_is_readable():
|
|
55
|
+
loc = Locator(android="id=add", default="id=x")
|
|
56
|
+
assert repr(loc) == "Locator(android='id=add', default='id=x')"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from crosslocator import Platform, get_current_platform, set_current_platform
|
|
4
|
+
from crosslocator.platform import ENV_VAR, normalize_platform, set_platform_provider
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_normalize_aliases():
|
|
8
|
+
assert normalize_platform("Android") is Platform.ANDROID
|
|
9
|
+
assert normalize_platform("iPadOS") is Platform.IPAD
|
|
10
|
+
assert normalize_platform("win") is Platform.WINDOWS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_unknown_platform_raises():
|
|
14
|
+
with pytest.raises(ValueError):
|
|
15
|
+
normalize_platform("symbian")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_explicit_platform_takes_priority(monkeypatch):
|
|
19
|
+
monkeypatch.setenv(ENV_VAR, "android")
|
|
20
|
+
set_current_platform("ios")
|
|
21
|
+
try:
|
|
22
|
+
assert get_current_platform() is Platform.IOS
|
|
23
|
+
finally:
|
|
24
|
+
set_current_platform(None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_env_var_used_when_no_explicit(monkeypatch):
|
|
28
|
+
set_current_platform(None)
|
|
29
|
+
monkeypatch.setenv(ENV_VAR, "windows")
|
|
30
|
+
assert get_current_platform() is Platform.WINDOWS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_provider_used_as_last_resort(monkeypatch):
|
|
34
|
+
set_current_platform(None)
|
|
35
|
+
monkeypatch.delenv(ENV_VAR, raising=False)
|
|
36
|
+
set_platform_provider(lambda: "android")
|
|
37
|
+
try:
|
|
38
|
+
assert get_current_platform() is Platform.ANDROID
|
|
39
|
+
finally:
|
|
40
|
+
set_platform_provider(None)
|