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.
@@ -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]})