robotframework-maestrolibrary 0.2.0__py3-none-any.whl
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.
- MaestroLibrary/__init__.py +231 -0
- MaestroLibrary/keywords/__init__.py +8 -0
- MaestroLibrary/keywords/_applicationmanagement.py +203 -0
- MaestroLibrary/keywords/_device.py +92 -0
- MaestroLibrary/keywords/_element.py +221 -0
- MaestroLibrary/keywords/_keyevent.py +34 -0
- MaestroLibrary/keywords/_runonfailure.py +16 -0
- MaestroLibrary/keywords/_screenshot.py +170 -0
- MaestroLibrary/keywords/_touch.py +75 -0
- MaestroLibrary/keywords/_waiting.py +58 -0
- MaestroLibrary/locators.py +59 -0
- MaestroLibrary/mcp.py +126 -0
- MaestroLibrary/py.typed +0 -0
- robotframework_maestrolibrary-0.2.0.dist-info/METADATA +131 -0
- robotframework_maestrolibrary-0.2.0.dist-info/RECORD +18 -0
- robotframework_maestrolibrary-0.2.0.dist-info/WHEEL +5 -0
- robotframework_maestrolibrary-0.2.0.dist-info/licenses/LICENSE +21 -0
- robotframework_maestrolibrary-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
|
|
9
|
+
from robot.api import logger
|
|
10
|
+
from robotlibcore import DynamicCore
|
|
11
|
+
|
|
12
|
+
from .keywords import ApplicationManagementKeywords, DeviceKeywords, ElementKeywords, KeyeventKeywords, RunOnFailureKeywords, ScreenshotKeywords, TouchKeywords, WaitingKeywords
|
|
13
|
+
from .locators import matches, walk
|
|
14
|
+
from .mcp import MaestroError, MaestroMCP
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
17
|
+
NO_APP = "maestro.no.app"
|
|
18
|
+
SETTLE_TIMEOUT_MS = 3000
|
|
19
|
+
APP_LAUNCH_TIMEOUT_S = 20 # Appium's default appWaitDuration
|
|
20
|
+
ADB_TIMEOUT_S = 10
|
|
21
|
+
MCP_TIMEOUT_S = 300 # how long to wait for one Maestro command flow beyond its own timeouts
|
|
22
|
+
FLOW_TIMEOUT_S = 3600 # a Run Flow file or directory can hold any number of commands
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MaestroLibrary(DynamicCore):
|
|
26
|
+
"""MaestroLibrary drives Android and iOS apps through [https://maestro.dev|Maestro].
|
|
27
|
+
|
|
28
|
+
It mirrors [https://github.com/serhatbolsu/robotframework-appiumlibrary|AppiumLibrary]:
|
|
29
|
+
the keyword names, the argument order and the `strategy=value` locators are the same,
|
|
30
|
+
so most AppiumLibrary suites read the same with this library. There's no Appium server
|
|
31
|
+
and no capabilities, and Maestro waits for the UI to settle after every action.
|
|
32
|
+
|
|
33
|
+
= Locating elements =
|
|
34
|
+
|
|
35
|
+
| *Strategy* | *Example* | *Matches* |
|
|
36
|
+
| text | ``text=Log in`` | whole text, content-desc or hint, literally |
|
|
37
|
+
| accessibility_id | ``accessibility_id=Back`` | same as text (Maestro matches content-desc as text) |
|
|
38
|
+
| id | ``id=login_button`` | whole resource-id or Flutter ``Semantics.identifier`` |
|
|
39
|
+
| regex | ``regex=Log.*`` | text, as a Maestro (Java) regular expression |
|
|
40
|
+
| id_regex | ``id_regex=.*login`` | id, as a regular expression |
|
|
41
|
+
| point | ``point=50%,90%`` | a screen position, in percent or pixels |
|
|
42
|
+
|
|
43
|
+
A locator without a strategy is treated as text, because Flutter widgets expose
|
|
44
|
+
text far more often than ids. xpath, class, android, ios, predicate, chain, css, name and identifier
|
|
45
|
+
have no Maestro equivalent and fail immediately.
|
|
46
|
+
|
|
47
|
+
Actions match on the device with Java regular expressions. The `Should` and `Get`
|
|
48
|
+
keywords match the inspected screen with Python's ``re``. The two behave the same for
|
|
49
|
+
literal locators and for common patterns. Keep ``regex=`` and ``id_regex=`` to
|
|
50
|
+
syntax that both engines share (no possessive quantifiers, ``\\p{...}`` classes or inline flags).
|
|
51
|
+
|
|
52
|
+
= Timeouts =
|
|
53
|
+
|
|
54
|
+
`timeout` (default 5 seconds) is the default for the `Wait Until` keywords and `Expect`
|
|
55
|
+
keywords, the same as AppiumLibrary. The `Should` keywords check the current screen once
|
|
56
|
+
and don't wait. Every action waits for its element on its own.
|
|
57
|
+
|
|
58
|
+
= Anything else =
|
|
59
|
+
|
|
60
|
+
`Run Flow` runs raw Maestro YAML or a flow file, for the Maestro commands that have
|
|
61
|
+
no keyword yet.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
ROBOT_LIBRARY_SCOPE = "GLOBAL"
|
|
65
|
+
ROBOT_LIBRARY_VERSION = __version__
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
timeout: timedelta = timedelta(seconds=5),
|
|
70
|
+
run_on_failure: str = "Capture Page Screenshot",
|
|
71
|
+
device: str | None = None,
|
|
72
|
+
maestro: str = "maestro",
|
|
73
|
+
speed: timedelta = timedelta(0),
|
|
74
|
+
):
|
|
75
|
+
"""`device` is a Maestro device id such as ``emulator-5554``. By default the first
|
|
76
|
+
connected device is used. `maestro` is the Maestro CLI executable.
|
|
77
|
+
|
|
78
|
+
`speed` pauses before every Maestro command, like SeleniumLibrary's speed. Raise it
|
|
79
|
+
(for example ``speed=0.5s``) when a slow or overloaded emulator stops responding.
|
|
80
|
+
"""
|
|
81
|
+
self.timeout = timeout
|
|
82
|
+
self.speed = speed
|
|
83
|
+
self.run_on_failure_keyword = None if run_on_failure.upper() in ("NOTHING", "NONE", "") else run_on_failure
|
|
84
|
+
self.device = device
|
|
85
|
+
self.platform = None
|
|
86
|
+
self.app_id = None
|
|
87
|
+
self.mcp = MaestroMCP([maestro, "mcp"])
|
|
88
|
+
self._running_on_failure = False
|
|
89
|
+
atexit.register(self.mcp.close)
|
|
90
|
+
DynamicCore.__init__(self, [
|
|
91
|
+
ApplicationManagementKeywords(self),
|
|
92
|
+
ElementKeywords(self),
|
|
93
|
+
WaitingKeywords(self),
|
|
94
|
+
TouchKeywords(self),
|
|
95
|
+
DeviceKeywords(self),
|
|
96
|
+
KeyeventKeywords(self),
|
|
97
|
+
ScreenshotKeywords(self),
|
|
98
|
+
RunOnFailureKeywords(self),
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
def run_keyword(self, name, args, kwargs=None):
|
|
102
|
+
try:
|
|
103
|
+
return DynamicCore.run_keyword(self, name, args, kwargs)
|
|
104
|
+
except Exception:
|
|
105
|
+
self.run_on_failure()
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
def run_on_failure(self):
|
|
109
|
+
if not self.run_on_failure_keyword or self._running_on_failure:
|
|
110
|
+
return
|
|
111
|
+
self._running_on_failure = True
|
|
112
|
+
try:
|
|
113
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
114
|
+
BuiltIn().run_keyword(self.run_on_failure_keyword)
|
|
115
|
+
except Exception as err:
|
|
116
|
+
logger.warn(f"Keyword '{self.run_on_failure_keyword}' could not be run on failure: {err}")
|
|
117
|
+
finally:
|
|
118
|
+
self._running_on_failure = False
|
|
119
|
+
|
|
120
|
+
def device_id(self):
|
|
121
|
+
if not self.platform:
|
|
122
|
+
content = self.mcp.call_tool("list_devices", {})
|
|
123
|
+
connected = [d for d in json.loads(content[0]["text"])["devices"] if d.get("connected")]
|
|
124
|
+
if self.device:
|
|
125
|
+
connected = [d for d in connected if d["device_id"] == self.device]
|
|
126
|
+
if not connected:
|
|
127
|
+
raise MaestroError(f"Device '{self.device or 'any'}' is not connected. Start an emulator or "
|
|
128
|
+
"simulator, or connect a device.")
|
|
129
|
+
self.device, self.platform = connected[0]["device_id"], connected[0]["platform"]
|
|
130
|
+
logger.info(f"Using {self.platform} device '{self.device}'.")
|
|
131
|
+
return self.device
|
|
132
|
+
|
|
133
|
+
def wait_for_app_focus(self, app_id):
|
|
134
|
+
"""Waits until `app_id` has window focus, like Appium waiting for the app's activity.
|
|
135
|
+
|
|
136
|
+
Maestro's launchApp returns while the launch transition is still running, so the
|
|
137
|
+
first inspect_screen can show the previous app. Android only: other platforms return at once.
|
|
138
|
+
"""
|
|
139
|
+
self.device_id()
|
|
140
|
+
if self.platform != "android":
|
|
141
|
+
return
|
|
142
|
+
deadline = time.monotonic() + APP_LAUNCH_TIMEOUT_S
|
|
143
|
+
while time.monotonic() < deadline:
|
|
144
|
+
focus = self.adb_shell("dumpsys", "window", purpose="waiting for the app's window")
|
|
145
|
+
if focus is None:
|
|
146
|
+
return
|
|
147
|
+
if re.search(rf"mCurrentFocus=.*\s{re.escape(app_id)}/", focus):
|
|
148
|
+
# Focus arrives before the first frames are drawn; a gesture sent then can be lost.
|
|
149
|
+
self.run_commands({"waitForAnimationToEnd": {"timeout": SETTLE_TIMEOUT_MS}}, log=False)
|
|
150
|
+
return
|
|
151
|
+
# dumpsys window is a heavy call; focus arrived within 2 s in measurements, so 1 s is enough.
|
|
152
|
+
time.sleep(1)
|
|
153
|
+
logger.warn(f"App '{app_id}' did not get window focus within {APP_LAUNCH_TIMEOUT_S} s.")
|
|
154
|
+
|
|
155
|
+
def adb_shell(self, *args, purpose):
|
|
156
|
+
"""Runs `adb shell args` on the current Android device and returns its output.
|
|
157
|
+
|
|
158
|
+
Returns None, with a warning naming `purpose`, when adb is missing or doesn't answer:
|
|
159
|
+
Maestro itself needs no adb, so the checks built on it degrade instead of failing.
|
|
160
|
+
"""
|
|
161
|
+
adb = shutil.which("adb")
|
|
162
|
+
if not adb:
|
|
163
|
+
logger.warn(f"adb is not on PATH, so {purpose} is skipped. Install Android platform-tools.")
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
return subprocess.run([adb, "-s", self.device, "shell", *args], capture_output=True,
|
|
167
|
+
text=True, errors="replace", timeout=ADB_TIMEOUT_S).stdout
|
|
168
|
+
except subprocess.TimeoutExpired:
|
|
169
|
+
logger.warn(f"adb did not answer within {ADB_TIMEOUT_S} s while {purpose}; is the device responsive?")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def run_commands(self, *commands, app_id=None, log=True):
|
|
173
|
+
"""Runs Maestro commands (dicts or strings) as one flow on the current device."""
|
|
174
|
+
header = f"appId: {json.dumps(app_id or self.app_id or NO_APP)}\n---\n"
|
|
175
|
+
body = "\n".join(f"- {json.dumps(command)}" for command in commands)
|
|
176
|
+
if log:
|
|
177
|
+
logger.debug(f"Maestro flow:\n{body}")
|
|
178
|
+
if self.speed:
|
|
179
|
+
time.sleep(self.speed.total_seconds())
|
|
180
|
+
# A wait or scroll may legitimately take longer than the default; allow its own timeout.
|
|
181
|
+
waits = [c[k]["timeout"] for c in commands if isinstance(c, dict) for k in c
|
|
182
|
+
if isinstance(c[k], dict) and isinstance(c[k].get("timeout"), int)]
|
|
183
|
+
self._run({"yaml": header + body}, None, max([MCP_TIMEOUT_S] + [w / 1000 + 60 for w in waits]))
|
|
184
|
+
|
|
185
|
+
def run_yaml(self, yaml, env=None):
|
|
186
|
+
self._run({"yaml": yaml}, env, FLOW_TIMEOUT_S)
|
|
187
|
+
|
|
188
|
+
def run_files(self, files, env=None):
|
|
189
|
+
self._run({"files": files}, env, FLOW_TIMEOUT_S)
|
|
190
|
+
|
|
191
|
+
def run_dir(self, path, env=None, include_tags=None, exclude_tags=None):
|
|
192
|
+
flow = {"dir": path}
|
|
193
|
+
if include_tags is not None:
|
|
194
|
+
flow["include_tags"] = include_tags
|
|
195
|
+
if exclude_tags is not None:
|
|
196
|
+
flow["exclude_tags"] = exclude_tags
|
|
197
|
+
self._run(flow, env, FLOW_TIMEOUT_S)
|
|
198
|
+
|
|
199
|
+
def _run(self, flow, env, timeout):
|
|
200
|
+
arguments = {"device_id": self.device_id(), **flow}
|
|
201
|
+
if env:
|
|
202
|
+
arguments["env"] = {k: str(v) for k, v in env.items()}
|
|
203
|
+
self.mcp.call_tool("run", arguments, timeout=timeout)
|
|
204
|
+
|
|
205
|
+
def screen(self):
|
|
206
|
+
"""Returns the settled current screen's element tree, from Maestro's inspect_screen.
|
|
207
|
+
|
|
208
|
+
Actions return before transitions finish, so inspecting at once can see the
|
|
209
|
+
previous screen. Waiting for animations first makes snapshot checks deterministic.
|
|
210
|
+
"""
|
|
211
|
+
self.run_commands({"waitForAnimationToEnd": {"timeout": SETTLE_TIMEOUT_MS}}, log=False)
|
|
212
|
+
content = self.mcp.call_tool("inspect_screen", {"device_id": self.device_id()})
|
|
213
|
+
return json.loads(content[0]["text"])["elements"]
|
|
214
|
+
|
|
215
|
+
def elements(self):
|
|
216
|
+
"""Returns every element on the current screen, flattened."""
|
|
217
|
+
return list(walk(self.screen()))
|
|
218
|
+
|
|
219
|
+
def timeout_ms(self, timeout=None):
|
|
220
|
+
return int((self.timeout if timeout is None else timeout).total_seconds() * 1000)
|
|
221
|
+
|
|
222
|
+
def wait_until(self, selector, visible=True, timeout=None, error=None):
|
|
223
|
+
"""Waits for a selector to become visible (or not visible) using Maestro's extendedWaitUntil."""
|
|
224
|
+
state = "visible" if visible else "notVisible"
|
|
225
|
+
try:
|
|
226
|
+
self.run_commands({"extendedWaitUntil": {state: selector, "timeout": self.timeout_ms(timeout)}})
|
|
227
|
+
except MaestroError as err:
|
|
228
|
+
raise AssertionError(error or str(err)) from None
|
|
229
|
+
|
|
230
|
+
def find(self, selector):
|
|
231
|
+
return [e for e in self.elements() if matches(e, selector)]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from ._applicationmanagement import ApplicationManagementKeywords
|
|
2
|
+
from ._element import ElementKeywords
|
|
3
|
+
from ._runonfailure import RunOnFailureKeywords
|
|
4
|
+
from ._screenshot import ScreenshotKeywords
|
|
5
|
+
from ._waiting import WaitingKeywords
|
|
6
|
+
from ._touch import TouchKeywords
|
|
7
|
+
from ._keyevent import KeyeventKeywords
|
|
8
|
+
from ._device import DeviceKeywords
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
|
|
7
|
+
from robot.api import logger
|
|
8
|
+
from robotlibcore import keyword
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PERMISSION_STATES = ("allow", "deny", "unset")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_permissions(permissions: dict[str, str]) -> dict[str, str]:
|
|
15
|
+
for name, state in permissions.items():
|
|
16
|
+
if state not in PERMISSION_STATES:
|
|
17
|
+
raise ValueError(f"Permission '{name}' must be allow, deny or unset, not '{state}'.")
|
|
18
|
+
return permissions
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApplicationManagementKeywords:
|
|
22
|
+
def __init__(self, lib):
|
|
23
|
+
self.lib = lib
|
|
24
|
+
|
|
25
|
+
@keyword
|
|
26
|
+
def open_application(self, app_id: str, clear_state: bool = False, stop_app: bool = True,
|
|
27
|
+
permissions: dict[str, str] | None = None):
|
|
28
|
+
"""Launches the app with package name / bundle id `app_id` and makes it the current app.
|
|
29
|
+
|
|
30
|
+
`clear_state` wipes the app's data first, which is a fresh install for practical purposes.
|
|
31
|
+
`stop_app` restarts the app if it is already running. Without `permissions` Maestro
|
|
32
|
+
grants every runtime permission on launch. `permissions` sets them instead, with
|
|
33
|
+
values allow, deny or unset, for example ``{'all': 'deny'}``.
|
|
34
|
+
|
|
35
|
+
Unlike AppiumLibrary there's no remote URL or capabilities: Maestro talks to the
|
|
36
|
+
device directly.
|
|
37
|
+
|
|
38
|
+
| `Open Application` | com.example.app | clear_state=True |
|
|
39
|
+
| `Open Application` | com.example.app | permissions={'all': 'deny'} |
|
|
40
|
+
"""
|
|
41
|
+
launch = {"appId": app_id, "clearState": clear_state, "stopApp": stop_app}
|
|
42
|
+
if permissions is not None:
|
|
43
|
+
launch["permissions"] = check_permissions(permissions)
|
|
44
|
+
self.lib.run_commands({"launchApp": launch}, app_id=app_id)
|
|
45
|
+
self.lib.app_id = app_id
|
|
46
|
+
self.lib.wait_for_app_focus(app_id)
|
|
47
|
+
|
|
48
|
+
@keyword
|
|
49
|
+
def clear_application_state(self, app_id: str | None = None):
|
|
50
|
+
"""Wipes the data of `app_id`, or of the current app, as if it were freshly installed.
|
|
51
|
+
|
|
52
|
+
| `Clear Application State` | com.example.app |
|
|
53
|
+
"""
|
|
54
|
+
app_id = self._app_id(app_id)
|
|
55
|
+
self.lib.run_commands({"clearState": app_id}, app_id=app_id)
|
|
56
|
+
|
|
57
|
+
@keyword
|
|
58
|
+
def kill_application(self, app_id: str | None = None):
|
|
59
|
+
"""Kills `app_id`, or the current app, the way the system does when it reclaims memory.
|
|
60
|
+
It only kills an app in the background: a foreground app keeps running. Unlike
|
|
61
|
+
`Terminate Application`, which stops the app, this tests that the app restores its state.
|
|
62
|
+
|
|
63
|
+
| `Kill Application` | com.example.app |
|
|
64
|
+
"""
|
|
65
|
+
app_id = self._app_id(app_id)
|
|
66
|
+
self.lib.run_commands({"killApp": app_id}, app_id=app_id)
|
|
67
|
+
|
|
68
|
+
@keyword
|
|
69
|
+
def set_application_permissions(self, app_id: str | None = None, **permissions: str):
|
|
70
|
+
"""Sets runtime permissions of `app_id`, or of the current app, to allow, deny or unset.
|
|
71
|
+
|
|
72
|
+
Each named argument is a permission and its state, for example ``all=deny``, or
|
|
73
|
+
``camera=allow``. ``unset`` returns the permission to the system default.
|
|
74
|
+
|
|
75
|
+
| `Set Application Permissions` | com.example.app | camera=allow | notifications=unset |
|
|
76
|
+
"""
|
|
77
|
+
app_id = self._app_id(app_id)
|
|
78
|
+
if not permissions:
|
|
79
|
+
raise ValueError("No permissions given. Name them as arguments, for example camera=allow.")
|
|
80
|
+
self.lib.run_commands({"setPermissions": {"appId": app_id, "permissions": check_permissions(permissions)}},
|
|
81
|
+
app_id=app_id)
|
|
82
|
+
|
|
83
|
+
def _app_id(self, app_id):
|
|
84
|
+
app_id = app_id or self.lib.app_id
|
|
85
|
+
if not app_id:
|
|
86
|
+
raise ValueError("No app_id given and no application is open.")
|
|
87
|
+
return app_id
|
|
88
|
+
|
|
89
|
+
@keyword
|
|
90
|
+
def close_application(self):
|
|
91
|
+
"""Stops the current app."""
|
|
92
|
+
if self.lib.app_id:
|
|
93
|
+
self.lib.run_commands({"stopApp": self.lib.app_id})
|
|
94
|
+
self.lib.app_id = None
|
|
95
|
+
|
|
96
|
+
@keyword
|
|
97
|
+
def close_all_applications(self):
|
|
98
|
+
"""Stops the current app and shuts the Maestro session down. Use it in suite teardown."""
|
|
99
|
+
try:
|
|
100
|
+
if self.lib.mcp.running:
|
|
101
|
+
self.close_application()
|
|
102
|
+
finally:
|
|
103
|
+
self.lib.mcp.close()
|
|
104
|
+
|
|
105
|
+
@keyword
|
|
106
|
+
def activate_application(self, app_id: str):
|
|
107
|
+
"""Brings `app_id` to the foreground without restarting it, and makes it the current app."""
|
|
108
|
+
self.lib.run_commands({"launchApp": {"appId": app_id, "stopApp": False}}, app_id=app_id)
|
|
109
|
+
self.lib.app_id = app_id
|
|
110
|
+
self.lib.wait_for_app_focus(app_id)
|
|
111
|
+
|
|
112
|
+
@keyword
|
|
113
|
+
def terminate_application(self, app_id: str):
|
|
114
|
+
"""Stops `app_id`."""
|
|
115
|
+
self.lib.run_commands({"stopApp": app_id}, app_id=app_id)
|
|
116
|
+
|
|
117
|
+
@keyword
|
|
118
|
+
def go_back(self):
|
|
119
|
+
"""Presses the Android back button."""
|
|
120
|
+
self.lib.run_commands("back")
|
|
121
|
+
|
|
122
|
+
@keyword
|
|
123
|
+
def go_to_url(self, url: str):
|
|
124
|
+
"""Opens `url`. A deep link opens in its app, a web URL in the default browser."""
|
|
125
|
+
self.lib.run_commands({"openLink": url})
|
|
126
|
+
|
|
127
|
+
@keyword
|
|
128
|
+
def get_source(self) -> str:
|
|
129
|
+
"""Returns the current screen's view hierarchy as JSON (Maestro ``inspect_screen``)."""
|
|
130
|
+
return json.dumps(self.lib.screen(), indent=1)
|
|
131
|
+
|
|
132
|
+
@keyword
|
|
133
|
+
def log_source(self, loglevel: str = "INFO") -> str:
|
|
134
|
+
"""Logs and returns the current screen's view hierarchy."""
|
|
135
|
+
source = self.get_source()
|
|
136
|
+
logger.write(source, loglevel)
|
|
137
|
+
return source
|
|
138
|
+
|
|
139
|
+
@keyword
|
|
140
|
+
def set_maestro_timeout(self, seconds: timedelta) -> timedelta:
|
|
141
|
+
"""Sets the default timeout of the `Wait Until` and `Expect` keywords and returns the old one."""
|
|
142
|
+
old, self.lib.timeout = self.lib.timeout, seconds
|
|
143
|
+
return old
|
|
144
|
+
|
|
145
|
+
@keyword
|
|
146
|
+
def get_maestro_timeout(self) -> timedelta:
|
|
147
|
+
"""Returns the default timeout of the `Wait Until` and `Expect` keywords."""
|
|
148
|
+
return self.lib.timeout
|
|
149
|
+
|
|
150
|
+
@keyword
|
|
151
|
+
def run_flow(self, flow: str, *, include_tags: list[str] | str | None = None,
|
|
152
|
+
exclude_tags: list[str] | str | None = None, **env: str):
|
|
153
|
+
"""Runs a Maestro flow file, a directory of flows, or inline YAML commands, on the current device.
|
|
154
|
+
|
|
155
|
+
Use it for the Maestro commands that have no keyword. Inline YAML without a config section
|
|
156
|
+
gets the current app's ``appId``. Named arguments become flow environment
|
|
157
|
+
variables (``${NAME}`` in the flow).
|
|
158
|
+
|
|
159
|
+
A directory runs every flow in it. `include_tags` and `exclude_tags` pick flows by
|
|
160
|
+
their ``tags``, and are only valid with a directory. Give several tags as a list
|
|
161
|
+
variable, such as ``@{TAGS}``.
|
|
162
|
+
|
|
163
|
+
| `Run Flow` | ${CURDIR}/flows/onboarding.yaml | USER=demo |
|
|
164
|
+
| `Run Flow` | ${CURDIR}/flows/ | include_tags=smoke | exclude_tags=slow |
|
|
165
|
+
| `Run Flow` | - tapOn:\\n text: Next\\n index: 1 |
|
|
166
|
+
"""
|
|
167
|
+
include_tags = [include_tags] if isinstance(include_tags, str) else include_tags
|
|
168
|
+
exclude_tags = [exclude_tags] if isinstance(exclude_tags, str) else exclude_tags
|
|
169
|
+
if os.path.isdir(flow):
|
|
170
|
+
self.lib.run_dir(os.path.abspath(flow), env, include_tags, exclude_tags)
|
|
171
|
+
return
|
|
172
|
+
if include_tags is not None or exclude_tags is not None:
|
|
173
|
+
raise ValueError("include_tags and exclude_tags only apply to a flow directory.")
|
|
174
|
+
if os.path.isfile(flow):
|
|
175
|
+
# Passed by path so the flow's relative runFlow/runScript paths resolve next to it.
|
|
176
|
+
self.lib.run_files([os.path.abspath(flow)], env)
|
|
177
|
+
return
|
|
178
|
+
if "\n" not in flow and not flow.lstrip().startswith("-") and flow.rstrip().endswith((".yaml", ".yml", "/", "\\")):
|
|
179
|
+
raise ValueError(f"Flow file or directory not found: {flow}")
|
|
180
|
+
if not any(line.strip() == "---" for line in flow.splitlines()):
|
|
181
|
+
flow = f"appId: {json.dumps(self.lib.app_id or 'maestro.no.app')}\n---\n{flow}"
|
|
182
|
+
self.lib.run_yaml(flow, env)
|
|
183
|
+
|
|
184
|
+
@keyword
|
|
185
|
+
def execute_adb_shell(self, command: str, *args: str, timeout: timedelta = timedelta(seconds=30)) -> str:
|
|
186
|
+
"""Runs `adb shell command args` on the current Android device and returns its output.
|
|
187
|
+
|
|
188
|
+
Fails if the command doesn't finish within `timeout` (AppiumLibrary has a separate
|
|
189
|
+
`Execute Adb Shell Timeout` keyword for this).
|
|
190
|
+
"""
|
|
191
|
+
adb = shutil.which("adb")
|
|
192
|
+
if not adb:
|
|
193
|
+
raise AssertionError("adb is not on PATH. Install Android platform-tools.")
|
|
194
|
+
try:
|
|
195
|
+
result = subprocess.run(
|
|
196
|
+
[adb, "-s", self.lib.device_id(), "shell", command, *args],
|
|
197
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout.total_seconds(),
|
|
198
|
+
)
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
raise AssertionError(f"adb shell {command} timed out after {timeout.total_seconds():g} s.") from None
|
|
201
|
+
if result.returncode:
|
|
202
|
+
raise AssertionError(f"adb shell {command} failed ({result.returncode}): {result.stderr.strip()}")
|
|
203
|
+
return result.stdout
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from robotlibcore import keyword
|
|
4
|
+
|
|
5
|
+
ORIENTATIONS = ("PORTRAIT", "LANDSCAPE_LEFT", "LANDSCAPE_RIGHT", "UPSIDE_DOWN")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def maestro_path(path):
|
|
9
|
+
"""`path` as Maestro needs it: absolute, with forward slashes (also on Windows)."""
|
|
10
|
+
return os.path.abspath(path).replace(os.sep, "/")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeviceKeywords:
|
|
14
|
+
def __init__(self, lib):
|
|
15
|
+
self.lib = lib
|
|
16
|
+
|
|
17
|
+
@keyword
|
|
18
|
+
def set_location(self, latitude: float, longitude: float, altitude: float | None = None):
|
|
19
|
+
"""Sets the device's location (a mock location on Android).
|
|
20
|
+
|
|
21
|
+
`altitude` is accepted for AppiumLibrary compatibility and ignored: Maestro sets only
|
|
22
|
+
latitude and longitude.
|
|
23
|
+
|
|
24
|
+
| `Set Location` | 33.3152 | 44.3661 |
|
|
25
|
+
"""
|
|
26
|
+
self.lib.run_commands({"setLocation": {"latitude": str(latitude), "longitude": str(longitude)}})
|
|
27
|
+
|
|
28
|
+
@keyword
|
|
29
|
+
def travel(self, *points: str, speed: float | None = None):
|
|
30
|
+
"""Moves the device's location through `points` (``latitude,longitude``) at `speed` metres per
|
|
31
|
+
second, or Maestro's default speed.
|
|
32
|
+
|
|
33
|
+
| `Travel` | 33.3152,44.3661 | 33.3200,44.3700 | speed=20 |
|
|
34
|
+
"""
|
|
35
|
+
if len(points) < 2:
|
|
36
|
+
raise ValueError("Travel needs at least two points.")
|
|
37
|
+
command = {"points": list(points)}
|
|
38
|
+
if speed is not None:
|
|
39
|
+
command["speed"] = speed
|
|
40
|
+
self.lib.run_commands({"travel": command})
|
|
41
|
+
|
|
42
|
+
@keyword
|
|
43
|
+
def landscape(self):
|
|
44
|
+
"""Rotates the device to landscape (``LANDSCAPE_LEFT``)."""
|
|
45
|
+
self.set_orientation("LANDSCAPE_LEFT")
|
|
46
|
+
|
|
47
|
+
@keyword
|
|
48
|
+
def portrait(self):
|
|
49
|
+
"""Rotates the device to portrait."""
|
|
50
|
+
self.set_orientation("PORTRAIT")
|
|
51
|
+
|
|
52
|
+
@keyword
|
|
53
|
+
def set_orientation(self, orientation: str):
|
|
54
|
+
"""Rotates the device: ``PORTRAIT``, ``LANDSCAPE_LEFT``, ``LANDSCAPE_RIGHT`` or ``UPSIDE_DOWN``
|
|
55
|
+
(case and spaces don't matter).
|
|
56
|
+
|
|
57
|
+
| `Set Orientation` | landscape right |
|
|
58
|
+
"""
|
|
59
|
+
value = orientation.strip().upper().replace(" ", "_")
|
|
60
|
+
if value not in ORIENTATIONS:
|
|
61
|
+
raise ValueError(f"Unknown orientation '{orientation}'. Use one of: {', '.join(ORIENTATIONS)}.")
|
|
62
|
+
self.lib.run_commands({"setOrientation": value})
|
|
63
|
+
|
|
64
|
+
@keyword
|
|
65
|
+
def set_airplane_mode(self, enabled: bool):
|
|
66
|
+
"""Turns airplane mode on or off. With it on, the device has no network.
|
|
67
|
+
|
|
68
|
+
| `Set Airplane Mode` | True |
|
|
69
|
+
"""
|
|
70
|
+
self.lib.run_commands({"setAirplaneMode": "enabled" if enabled else "disabled"})
|
|
71
|
+
|
|
72
|
+
@keyword
|
|
73
|
+
def set_dark_mode(self, enabled: bool):
|
|
74
|
+
"""Turns the system dark theme on or off.
|
|
75
|
+
|
|
76
|
+
| `Set Dark Mode` | True |
|
|
77
|
+
"""
|
|
78
|
+
self.lib.run_commands({"setDarkMode": "enabled" if enabled else "disabled"})
|
|
79
|
+
|
|
80
|
+
@keyword
|
|
81
|
+
def add_media(self, *paths: str):
|
|
82
|
+
"""Adds image or video files to the device's gallery, for tests that pick or upload media.
|
|
83
|
+
On Android they land in ``Pictures/`` under their own file names.
|
|
84
|
+
|
|
85
|
+
| `Add Media` | ${CURDIR}/data/receipt.png |
|
|
86
|
+
"""
|
|
87
|
+
if not paths:
|
|
88
|
+
raise ValueError("Add Media needs at least one file.")
|
|
89
|
+
missing = [path for path in paths if not os.path.isfile(path)]
|
|
90
|
+
if missing:
|
|
91
|
+
raise ValueError(f"Media file does not exist: {', '.join(missing)}")
|
|
92
|
+
self.lib.run_commands({"addMedia": [maestro_path(path) for path in paths]})
|