jevtest 0.5.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.
- jevtest/__init__.py +3 -0
- jevtest/__main__.py +7 -0
- jevtest/adapters/__init__.py +4 -0
- jevtest/adapters/clock.py +17 -0
- jevtest/adapters/devices/__init__.py +1 -0
- jevtest/adapters/devices/android.py +556 -0
- jevtest/adapters/devices/android_agent/AndroidManifest.xml +14 -0
- jevtest/adapters/devices/android_agent/src/dev/jevtest/agent/Agent.java +305 -0
- jevtest/adapters/devices/common.py +174 -0
- jevtest/adapters/devices/ios.py +569 -0
- jevtest/adapters/devices/ios_agent/AgentHost/AppDelegate.swift +18 -0
- jevtest/adapters/devices/ios_agent/AgentUITests/JevAgentUITests.swift +346 -0
- jevtest/adapters/devices/ios_agent/JevAgent.xcodeproj/project.pbxproj +443 -0
- jevtest/adapters/devices/ios_agent/JevAgent.xcodeproj/xcshareddata/xcschemes/JevAgent.xcscheme +111 -0
- jevtest/adapters/jev/__init__.py +1 -0
- jevtest/adapters/jev/client.py +138 -0
- jevtest/adapters/jev/lockfile.py +161 -0
- jevtest/adapters/jev/wire.py +63 -0
- jevtest/adapters/reports/__init__.py +1 -0
- jevtest/adapters/reports/json_report.py +70 -0
- jevtest/adapters/reports/junit.py +44 -0
- jevtest/adapters/testfile/__init__.py +1 -0
- jevtest/adapters/testfile/discovery.py +35 -0
- jevtest/adapters/testfile/env.py +43 -0
- jevtest/adapters/testfile/loader.py +511 -0
- jevtest/application/__init__.py +1 -0
- jevtest/application/brain.py +236 -0
- jevtest/application/planning.py +25 -0
- jevtest/application/runner.py +459 -0
- jevtest/cli/__init__.py +1 -0
- jevtest/cli/console.py +201 -0
- jevtest/cli/main.py +83 -0
- jevtest/cli/run.py +270 -0
- jevtest/domain/__init__.py +4 -0
- jevtest/domain/decisions.py +148 -0
- jevtest/domain/failures.py +29 -0
- jevtest/domain/kinds.py +53 -0
- jevtest/domain/model.py +76 -0
- jevtest/domain/ports.py +208 -0
- jevtest/domain/results.py +122 -0
- jevtest/domain/rules.py +28 -0
- jevtest/domain/screen.py +128 -0
- jevtest/domain/steps.py +283 -0
- jevtest/domain/variables.py +26 -0
- jevtest/py.typed +0 -0
- jevtest-0.5.0.dist-info/METADATA +135 -0
- jevtest-0.5.0.dist-info/RECORD +51 -0
- jevtest-0.5.0.dist-info/WHEEL +5 -0
- jevtest-0.5.0.dist-info/entry_points.txt +2 -0
- jevtest-0.5.0.dist-info/licenses/LICENSE +21 -0
- jevtest-0.5.0.dist-info/top_level.txt +1 -0
jevtest/__init__.py
ADDED
jevtest/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Real time, for the runner's `Clock` port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SystemClock:
|
|
9
|
+
"""The system's monotonic clock."""
|
|
10
|
+
|
|
11
|
+
def now(self) -> float:
|
|
12
|
+
"""Seconds on a monotonic clock."""
|
|
13
|
+
return time.monotonic()
|
|
14
|
+
|
|
15
|
+
def sleep(self, seconds: float) -> None:
|
|
16
|
+
"""Wait."""
|
|
17
|
+
time.sleep(seconds)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The device adapters: Android (adb + an on-device agent) and iOS (Xcode tools + an XCUITest agent)."""
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
"""Android driver: adb for the app and input, a small on-device agent for reading the screen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
import xml.etree.ElementTree as ET
|
|
16
|
+
import zipfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from jevtest.domain.failures import DeviceError
|
|
20
|
+
from jevtest.domain.kinds import AppState, Orientation
|
|
21
|
+
from jevtest.domain.rules import SETTLE
|
|
22
|
+
from jevtest.domain.screen import Element, Point, Screen
|
|
23
|
+
|
|
24
|
+
from .common import BaseDevice, Progress, cache_dir, digest, run, run_bytes, start_process, stop_process
|
|
25
|
+
|
|
26
|
+
KEYCODES = {
|
|
27
|
+
"enter": 66, "delete": 67, "backspace": 67, "tab": 61, "escape": 111, "space": 62,
|
|
28
|
+
"back": 4, "home": 3, "menu": 82, "search": 84, "dpad_up": 19, "dpad_down": 20,
|
|
29
|
+
"dpad_left": 21, "dpad_right": 22, "volume_up": 24, "volume_down": 25, "power": 26,
|
|
30
|
+
"app_switch": 187, "move_end": 123, "move_home": 122,
|
|
31
|
+
}
|
|
32
|
+
ROTATIONS = {Orientation.PORTRAIT: 0, Orientation.LANDSCAPE: 1, Orientation.PORTRAIT_UPSIDE_DOWN: 2,
|
|
33
|
+
Orientation.LANDSCAPE_RIGHT: 3}
|
|
34
|
+
KINDS = {
|
|
35
|
+
"EditText": "text_field", "AutoCompleteTextView": "text_field", "Button": "button",
|
|
36
|
+
"ImageButton": "button", "CheckBox": "checkbox", "Switch": "switch", "ToggleButton": "switch",
|
|
37
|
+
"RadioButton": "radio", "ImageView": "image", "TextView": "text", "SeekBar": "slider",
|
|
38
|
+
"ProgressBar": "progress", "Spinner": "dropdown", "WebView": "webview",
|
|
39
|
+
"RecyclerView": "list", "ListView": "list", "ScrollView": "scroll_view",
|
|
40
|
+
}
|
|
41
|
+
EDITABLE = {"EditText", "AutoCompleteTextView"}
|
|
42
|
+
# Always report on/off for these: WebView checkboxes come through with checkable="false".
|
|
43
|
+
TOGGLES = {"CheckBox", "Switch", "RadioButton", "ToggleButton", "SwitchCompat", "SwitchMaterial"}
|
|
44
|
+
DOUBLE_TAP_GAP = 0.1 # Android and Flutter ignore taps < 40 ms apart and > 300 ms apart
|
|
45
|
+
DRAG_STEPS = 10 # finger positions along a drag
|
|
46
|
+
DRAG_HOLD = 0.1 # seconds the finger rests before lifting, so nothing flings
|
|
47
|
+
AGENT_START_TIMEOUT = 30
|
|
48
|
+
TOP_ACTIVITY = re.compile(r"topResumedActivity=ActivityRecord\{\S+ \S+ ([\w.]+)/")
|
|
49
|
+
PERMISSION_PROMPT = re.compile(r"com\.(google\.)?android\.permissioncontroller")
|
|
50
|
+
AGENT_SRC = Path(__file__).resolve().parent / "android_agent"
|
|
51
|
+
AGENT_ID = "dev.jevtest.agent"
|
|
52
|
+
AGENT_STOP_TIMEOUT = 10 # seconds for the agent to finish after /quit
|
|
53
|
+
AGENT_PORT = 7912 # on the device; adb forwards a free local port to it
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sdk_root() -> Path | None:
|
|
57
|
+
"""The Android SDK: ``$ANDROID_HOME``, ``$ANDROID_SDK_ROOT``, or Android Studio's default place."""
|
|
58
|
+
for var in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
|
|
59
|
+
if os.environ.get(var):
|
|
60
|
+
return Path(os.environ[var])
|
|
61
|
+
for p in (Path.home() / "Library/Android/sdk", Path.home() / "Android/Sdk"):
|
|
62
|
+
if p.exists():
|
|
63
|
+
return p
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tool(name: str, sdk_subpath: str, hint: str) -> str:
|
|
68
|
+
"""An SDK tool: on the PATH, or the highest version in the SDK."""
|
|
69
|
+
found = shutil.which(name)
|
|
70
|
+
if found:
|
|
71
|
+
return found
|
|
72
|
+
root = sdk_root()
|
|
73
|
+
matches = sorted(root.glob(sdk_subpath)) if root else []
|
|
74
|
+
if not matches:
|
|
75
|
+
raise DeviceError(f"{name} not found. {hint}")
|
|
76
|
+
return str(matches[-1]) # highest version for build-tools/*
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def adb_path() -> str:
|
|
80
|
+
"""Adb."""
|
|
81
|
+
return _tool("adb", "platform-tools/adb", "Install Android platform-tools or set ANDROID_HOME.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def aapt2_path() -> str:
|
|
85
|
+
"""aapt2, which reads an APK's package name."""
|
|
86
|
+
return _tool("aapt2", "build-tools/*/aapt2", "Install Android SDK build-tools (needed to read the APK).")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def devices() -> list[str]:
|
|
90
|
+
"""The serials of the connected devices that are ready (not offline or unauthorized)."""
|
|
91
|
+
out = run([adb_path(), "devices"], timeout=20)
|
|
92
|
+
return [line.split()[0] for line in out.splitlines()[1:] if line.strip().endswith("\tdevice")]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def device_names(serial: str) -> list[str]:
|
|
96
|
+
"""What a test file may call this device: its serial, its model ("Pixel 4a"), its emulator's AVD name."""
|
|
97
|
+
names = [serial, run([adb_path(), "-s", serial, "shell", "getprop", "ro.product.model"], check=False).strip()]
|
|
98
|
+
if serial.startswith("emulator-"):
|
|
99
|
+
names.append(run([adb_path(), "-s", serial, "emu", "avd", "name"], check=False).split("\n")[0].strip())
|
|
100
|
+
return [n for n in names if n]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def pick_device(wanted: str, serials: list[str]) -> str:
|
|
104
|
+
"""The one connected device with exactly this serial, model or AVD name."""
|
|
105
|
+
named = {serial: device_names(serial) for serial in serials}
|
|
106
|
+
matches = [serial for serial, names in named.items() if wanted in names]
|
|
107
|
+
listed = "; ".join(" / ".join(names) for names in named.values()) or "none"
|
|
108
|
+
if not matches:
|
|
109
|
+
raise DeviceError(f"No connected Android device called '{wanted}' (names are exact). Connected: {listed}")
|
|
110
|
+
if len(matches) > 1:
|
|
111
|
+
raise DeviceError(f"Several connected Android devices are called '{wanted}' ({', '.join(matches)}): "
|
|
112
|
+
"name one by its serial")
|
|
113
|
+
return matches[0]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def http_get(url: str, timeout: float) -> str:
|
|
117
|
+
"""A GET to the agent on its forwarded localhost port."""
|
|
118
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
|
119
|
+
body: bytes = resp.read()
|
|
120
|
+
return body.decode()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Devices tested at the same time share the cached agent: one builds it, the others wait for it.
|
|
124
|
+
AGENT_LOCK = threading.Lock()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_agent(progress: Progress) -> Path:
|
|
128
|
+
"""The agent APK, built once per source version and cached; one device builds it at a time."""
|
|
129
|
+
with AGENT_LOCK:
|
|
130
|
+
return _build_agent(progress)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_agent(progress: Progress) -> Path:
|
|
134
|
+
"""Compile the on-device agent with the SDK's own tools (no Gradle). Cached by source hash."""
|
|
135
|
+
version = digest(AGENT_SRC)
|
|
136
|
+
apk = cache_dir() / f"android-agent-{version}.apk"
|
|
137
|
+
if apk.exists():
|
|
138
|
+
return apk
|
|
139
|
+
root = sdk_root()
|
|
140
|
+
tools = sorted(root.glob("build-tools/*")) if root else []
|
|
141
|
+
jars = sorted(root.glob("platforms/android-*/android.jar")) if root else []
|
|
142
|
+
if not tools or not jars:
|
|
143
|
+
raise DeviceError("Android SDK build-tools and a platform are needed to build the jevtest agent")
|
|
144
|
+
bt, jar = tools[-1], str(jars[-1])
|
|
145
|
+
progress("building the Android agent (one time, a few seconds)")
|
|
146
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
147
|
+
t = Path(tmp)
|
|
148
|
+
run(["javac", "--release", "11", "-cp", jar, "-d", str(t / "classes"),
|
|
149
|
+
*map(str, sorted(AGENT_SRC.rglob("*.java")))])
|
|
150
|
+
run([str(bt / "d8"), "--min-api", "24", "--lib", jar, "--output", tmp,
|
|
151
|
+
*map(str, sorted((t / "classes").rglob("*.class")))])
|
|
152
|
+
run([str(bt / "aapt2"), "link", "--manifest", str(AGENT_SRC / "AndroidManifest.xml"), "-I", jar,
|
|
153
|
+
"--version-name", version, "-o", str(t / "base.apk")])
|
|
154
|
+
with zipfile.ZipFile(t / "base.apk", "a") as z:
|
|
155
|
+
z.write(t / "classes.dex", "classes.dex")
|
|
156
|
+
run([str(bt / "zipalign"), "-f", "4", str(t / "base.apk"), str(t / "aligned.apk")])
|
|
157
|
+
apk.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
keystore = cache_dir() / "jevtest-debug.keystore"
|
|
159
|
+
if not keystore.exists():
|
|
160
|
+
run(["keytool", "-genkeypair", "-keystore", str(keystore), "-storepass", "android",
|
|
161
|
+
"-alias", "jevtest", "-keypass", "android", "-keyalg", "RSA", "-validity", "10000",
|
|
162
|
+
"-dname", "CN=jevtest"])
|
|
163
|
+
run([str(bt / "apksigner"), "sign", "--ks", str(keystore), "--ks-pass", "pass:android",
|
|
164
|
+
"--out", str(apk), str(t / "aligned.apk")])
|
|
165
|
+
return apk
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def has_empty_webview(xml: str) -> bool:
|
|
169
|
+
"""Whether a WebView is on screen with no content yet (its page reaches the tree a moment later)."""
|
|
170
|
+
for node in ET.fromstring(xml).iter("node"):
|
|
171
|
+
if node.get("class") == "android.webkit.WebView" and len(node.findall(".//node")) == 0:
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_hierarchy(xml: str, width: int, height: int) -> list[Element]:
|
|
177
|
+
"""The agent's uiautomator-style XML as the elements a tester cares about, in document order."""
|
|
178
|
+
found = (_element(node.attrib, width, height) for node in ET.fromstring(xml).iter("node"))
|
|
179
|
+
return [el for el in found if el is not None]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
MIN_SIZE = 2 # pixels: anything thinner is off screen or invisible
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _element(a: dict[str, str], width: int, height: int) -> Element | None:
|
|
186
|
+
"""One node of the tree, or None if it's system UI, off screen, or carries nothing a test could use."""
|
|
187
|
+
if a.get("package") == "com.android.systemui": # status bar, navigation bar
|
|
188
|
+
return None
|
|
189
|
+
nums = re.findall(r"-?\d+", a.get("bounds", ""))
|
|
190
|
+
if len(nums) != len("xyxy"):
|
|
191
|
+
return None
|
|
192
|
+
x1, y1, x2, y2 = map(int, nums)
|
|
193
|
+
x1, y1, x2, y2 = max(x1, 0), max(y1, 0), min(x2, width), min(y2, height)
|
|
194
|
+
if x2 - x1 < MIN_SIZE or y2 - y1 < MIN_SIZE:
|
|
195
|
+
return None
|
|
196
|
+
cls = a.get("class", "").split(".")[-1]
|
|
197
|
+
text, desc = a.get("text", ""), a.get("content-desc", "")
|
|
198
|
+
label = f"{text} ({desc})" if text and desc and text != desc else (text or desc)
|
|
199
|
+
editable = cls in EDITABLE
|
|
200
|
+
clickable = a.get("clickable") == "true" or a.get("long-clickable") == "true"
|
|
201
|
+
checkable = a.get("checkable") == "true" or cls in TOGGLES
|
|
202
|
+
scrollable = a.get("scrollable") == "true"
|
|
203
|
+
full_id = a.get("resource-id", "")
|
|
204
|
+
rid = "" if full_id.startswith("android:id/") else full_id.split("/")[-1] # framework ids are structure
|
|
205
|
+
if not (label or editable or clickable or checkable or scrollable or rid):
|
|
206
|
+
return None
|
|
207
|
+
return Element(
|
|
208
|
+
kind=_kind(cls, clickable=clickable, password=editable and a.get("password") == "true"),
|
|
209
|
+
text=" ".join(label.split()), hint=a.get("hint", ""), resource_id=rid, bounds=(x1, y1, x2, y2),
|
|
210
|
+
enabled=a.get("enabled", "true") == "true", editable=editable, clickable=clickable, scrollable=scrollable,
|
|
211
|
+
focused=a.get("focused") == "true", checked=(a.get("checked") == "true") if checkable else None,
|
|
212
|
+
selected=a.get("selected") == "true", value=text if editable else "",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _kind(cls: str, *, clickable: bool, password: bool) -> str:
|
|
217
|
+
"""What an Android view class is, in jevtest's words."""
|
|
218
|
+
if password:
|
|
219
|
+
return "password_field"
|
|
220
|
+
if cls in KINDS:
|
|
221
|
+
return KINDS[cls]
|
|
222
|
+
if cls in ("View", ""): # Flutter and Compose render most widgets as plain Views
|
|
223
|
+
return "button" if clickable else "text"
|
|
224
|
+
return cls.lower()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class AndroidDevice(BaseDevice):
|
|
228
|
+
"""An Android phone or emulator, driven through adb and jevtest's on-device agent.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
device: The device's exact serial, model or emulator AVD name.
|
|
232
|
+
progress: Told about slow one-time work (building the agent).
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(self, device: str, progress: Progress) -> None:
|
|
236
|
+
self._progress = progress
|
|
237
|
+
self.adb = adb_path()
|
|
238
|
+
found = devices()
|
|
239
|
+
if not found:
|
|
240
|
+
raise DeviceError("No Android device connected. Start an emulator or connect a phone (see `adb devices`).")
|
|
241
|
+
self.serial = pick_device(device, found)
|
|
242
|
+
self.app_path: Path | None = None
|
|
243
|
+
self.activity = ""
|
|
244
|
+
self._size: tuple[int, int] | None = None
|
|
245
|
+
self.port = 0
|
|
246
|
+
self.agent: subprocess.Popen[str] | None = None
|
|
247
|
+
self._restore: dict[str, str] = {} # what -> shell command that puts back what a step changed
|
|
248
|
+
self._start_agent()
|
|
249
|
+
|
|
250
|
+
# --- agent -------------------------------------------------------------------
|
|
251
|
+
def _start_agent(self) -> None:
|
|
252
|
+
"""Install the agent if it changed, then start it and forward a free local port to it."""
|
|
253
|
+
apk = build_agent(self._progress)
|
|
254
|
+
version = apk.stem.rsplit("-", 1)[1]
|
|
255
|
+
if f"versionName={version}" not in self.sh(f"dumpsys package {AGENT_ID} | grep versionName", check=False):
|
|
256
|
+
self.sh(f"pm uninstall {AGENT_ID}", check=False) # any older copy, whatever key signed it
|
|
257
|
+
run([self.adb, "-s", self.serial, "install", str(apk)], timeout=120)
|
|
258
|
+
self.sh(f"am force-stop {AGENT_ID}") # a previous run's agent would hold the port
|
|
259
|
+
self.port = int(run([self.adb, "-s", self.serial, "forward", "tcp:0", f"tcp:{AGENT_PORT}"]).strip())
|
|
260
|
+
self.agent = start_process(
|
|
261
|
+
[self.adb, "-s", self.serial, "shell", "am", "instrument", "-r", "-w", "-e", "port", str(AGENT_PORT),
|
|
262
|
+
f"{AGENT_ID}/.Agent"],
|
|
263
|
+
ready="ready=1", log=cache_dir() / f"android-agent-{self.serial}.log", timeout=AGENT_START_TIMEOUT)
|
|
264
|
+
|
|
265
|
+
def _agent(self, path: str, wait_ms: int = 0, extra: str = "") -> str:
|
|
266
|
+
"""Call the agent. `wait_ms` is how long it may wait for the screen before answering."""
|
|
267
|
+
url = f"http://127.0.0.1:{self.port}{path}" + (f"?ms={wait_ms}{extra}" if wait_ms else "")
|
|
268
|
+
try:
|
|
269
|
+
return http_get(url, timeout=wait_ms / 1000 + 10)
|
|
270
|
+
except OSError as e:
|
|
271
|
+
raise DeviceError(f"Lost the Android agent during {path} ({e})") from None
|
|
272
|
+
|
|
273
|
+
def close(self) -> None:
|
|
274
|
+
"""Stop the agent, remove the port forward, and put back what steps changed."""
|
|
275
|
+
if self.agent and self.agent.poll() is None:
|
|
276
|
+
with contextlib.suppress(DeviceError): # it may already be gone
|
|
277
|
+
self._agent("/quit")
|
|
278
|
+
# `am instrument -w` exits once the device has finished tearing down UI automation,
|
|
279
|
+
# which resets rotation state; only after that can a restore stick.
|
|
280
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
281
|
+
self.agent.wait(AGENT_STOP_TIMEOUT)
|
|
282
|
+
stop_process(self.agent)
|
|
283
|
+
run([self.adb, "-s", self.serial, "forward", "--remove", f"tcp:{self.port}"], check=False)
|
|
284
|
+
self.restore()
|
|
285
|
+
|
|
286
|
+
def restore(self) -> None:
|
|
287
|
+
"""Put back what steps changed (rotation, dark mode, network), as the device was before them."""
|
|
288
|
+
for command in self._restore.values():
|
|
289
|
+
self.sh(command, check=False)
|
|
290
|
+
self._restore.clear()
|
|
291
|
+
|
|
292
|
+
def wait_idle(self, timeout: float, quiet: float | None = None) -> None:
|
|
293
|
+
"""Return once the screen has stopped changing (for `quiet` seconds), or after `timeout` seconds."""
|
|
294
|
+
extra = f"&quiet={int(quiet * 1000)}" if quiet is not None else ""
|
|
295
|
+
self._agent("/idle", int(timeout * 1000), extra)
|
|
296
|
+
|
|
297
|
+
def wait_change(self, timeout: float) -> None:
|
|
298
|
+
"""Return as soon as the screen changes, or after `timeout` seconds."""
|
|
299
|
+
self._agent("/change", int(timeout * 1000))
|
|
300
|
+
|
|
301
|
+
# --- plumbing ------------------------------------------------------------
|
|
302
|
+
def sh(self, cmd: str, timeout: float = 60, *, check: bool = True) -> str:
|
|
303
|
+
"""Run a shell command on the device."""
|
|
304
|
+
return run([self.adb, "-s", self.serial, "shell", cmd], timeout=timeout, check=check)
|
|
305
|
+
|
|
306
|
+
def size(self, rotation: int = 0) -> tuple[int, int]:
|
|
307
|
+
"""The screen size in pixels, for the current rotation (1 and 3 are landscape)."""
|
|
308
|
+
if self._size is None:
|
|
309
|
+
found = re.findall(r"(\d+)x(\d+)", self.sh("wm size"))
|
|
310
|
+
if not found:
|
|
311
|
+
raise DeviceError("Could not read the screen size (`wm size`)")
|
|
312
|
+
w, h = found[-1] # an override size, if any, is listed last
|
|
313
|
+
self._size = (int(w), int(h))
|
|
314
|
+
w, h = self._size
|
|
315
|
+
return (h, w) if rotation in (1, 3) else (w, h)
|
|
316
|
+
|
|
317
|
+
# --- lifecycle -------------------------------------------------------------
|
|
318
|
+
def install(self, app_path: Path) -> str:
|
|
319
|
+
"""Install an .apk or .aab and return its package name."""
|
|
320
|
+
self.app_path = app_path
|
|
321
|
+
suffix = app_path.suffix.lower()
|
|
322
|
+
if suffix == ".apk":
|
|
323
|
+
self.app_id = run([aapt2_path(), "dump", "packagename", str(app_path)]).strip()
|
|
324
|
+
# No -g: permissions start ungranted, like a real install. Use a `grant:` step to pre-grant.
|
|
325
|
+
run([self.adb, "-s", self.serial, "install", "-r", "-t", str(app_path)], timeout=300)
|
|
326
|
+
elif suffix == ".aab":
|
|
327
|
+
self._install_bundle(app_path)
|
|
328
|
+
else:
|
|
329
|
+
raise DeviceError(f"Android needs an .apk or .aab, got {app_path.name}")
|
|
330
|
+
out = self.sh(f"cmd package resolve-activity --brief -c android.intent.category.LAUNCHER {self.app_id}")
|
|
331
|
+
lines = out.strip().splitlines()
|
|
332
|
+
self.activity = lines[-1].strip() if lines else ""
|
|
333
|
+
if "/" not in self.activity:
|
|
334
|
+
raise DeviceError(f"{self.app_id} has no launcher activity")
|
|
335
|
+
return self.app_id
|
|
336
|
+
|
|
337
|
+
def _install_bundle(self, app_path: Path) -> None:
|
|
338
|
+
"""Install an .aab through bundletool, which builds the APKs for this device."""
|
|
339
|
+
bundletool = shutil.which("bundletool")
|
|
340
|
+
if not bundletool:
|
|
341
|
+
raise DeviceError("bundletool is required to install .aab files (brew install bundletool)")
|
|
342
|
+
self.app_id = run([bundletool, "dump", "manifest", "--bundle", str(app_path),
|
|
343
|
+
"--xpath", "/manifest/@package"]).strip()
|
|
344
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
345
|
+
apks = Path(tmp) / "app.apks"
|
|
346
|
+
run([bundletool, "build-apks", "--bundle", str(app_path), "--output", str(apks),
|
|
347
|
+
"--connected-device", "--device-id", self.serial, "--adb", self.adb], timeout=600)
|
|
348
|
+
run([bundletool, "install-apks", "--apks", str(apks), "--device-id", self.serial,
|
|
349
|
+
"--adb", self.adb], timeout=300)
|
|
350
|
+
|
|
351
|
+
def launch(self) -> None:
|
|
352
|
+
"""Start the app's launcher activity and wait until it's shown."""
|
|
353
|
+
self.sh(f"am start -W -n {self.activity}")
|
|
354
|
+
|
|
355
|
+
def resume(self) -> None:
|
|
356
|
+
"""Bring the app back to the foreground without restarting it."""
|
|
357
|
+
self.sh(f"am start -W -n {self.activity}")
|
|
358
|
+
|
|
359
|
+
def stop(self) -> None:
|
|
360
|
+
"""Force-stop the app."""
|
|
361
|
+
self.sh(f"am force-stop {self.app_id}")
|
|
362
|
+
|
|
363
|
+
def clear_data(self) -> None:
|
|
364
|
+
"""Clear the app's data."""
|
|
365
|
+
self.sh(f"pm clear {self.app_id}")
|
|
366
|
+
|
|
367
|
+
def reinstall(self) -> None:
|
|
368
|
+
"""Uninstall and install the build again."""
|
|
369
|
+
if self.app_path is None:
|
|
370
|
+
raise DeviceError("Nothing to reinstall: no app was installed")
|
|
371
|
+
self.sh(f"pm uninstall {self.app_id}", check=False)
|
|
372
|
+
self.install(self.app_path)
|
|
373
|
+
|
|
374
|
+
def check_ready(self) -> None:
|
|
375
|
+
"""A phone that is asleep or locked shows no app to test. Say so; never wake or unlock it."""
|
|
376
|
+
out = self.sh("dumpsys power | grep -m1 mWakefulness=; dumpsys window | grep -m1 -E 'isKeyguardShowing='",
|
|
377
|
+
check=False)
|
|
378
|
+
if "mWakefulness=Awake" not in out or "isKeyguardShowing=true" in out:
|
|
379
|
+
raise DeviceError(f"Android device {self.serial} is asleep or locked: unlock it and keep it awake "
|
|
380
|
+
"during the run")
|
|
381
|
+
|
|
382
|
+
def app_state(self) -> AppState:
|
|
383
|
+
"""Where the app is: running at all, and whether it or its own permission prompt is on top."""
|
|
384
|
+
out = self.sh(f"pidof {self.app_id}; dumpsys activity activities | grep -m1 topResumedActivity",
|
|
385
|
+
check=False)
|
|
386
|
+
if not re.match(r"\d+", out.strip()):
|
|
387
|
+
return AppState.NOT_RUNNING
|
|
388
|
+
top = TOP_ACTIVITY.search(out)
|
|
389
|
+
# The app has left only when another app is on top. No top activity = mid-transition;
|
|
390
|
+
# a permission prompt the app asked for sits on top of it but belongs to it.
|
|
391
|
+
if not top or top.group(1) == self.app_id or PERMISSION_PROMPT.fullmatch(top.group(1)):
|
|
392
|
+
return AppState.FOREGROUND
|
|
393
|
+
return AppState.BACKGROUND
|
|
394
|
+
|
|
395
|
+
# --- observe ---------------------------------------------------------------
|
|
396
|
+
def _wait_for_typing(self) -> None:
|
|
397
|
+
"""Wait until a text field has focus and the keyboard is up.
|
|
398
|
+
|
|
399
|
+
Keys sent before the keyboard is connected are dropped. Not "the field under the tap": on a real phone
|
|
400
|
+
the keyboard slides up and the app scrolls the focused field out from under it.
|
|
401
|
+
"""
|
|
402
|
+
deadline = time.monotonic() + SETTLE
|
|
403
|
+
while True:
|
|
404
|
+
xml = self._agent("/tree")
|
|
405
|
+
root = ET.fromstring(xml)
|
|
406
|
+
w, h = self.size(int(root.get("rotation", "0")))
|
|
407
|
+
focused = any(el.editable and el.focused for el in parse_hierarchy(xml, w, h))
|
|
408
|
+
if focused and root.get("ime") == "true":
|
|
409
|
+
return
|
|
410
|
+
left = deadline - time.monotonic()
|
|
411
|
+
if left <= 0:
|
|
412
|
+
raise DeviceError("The text field did not get keyboard focus")
|
|
413
|
+
self.wait_change(left)
|
|
414
|
+
|
|
415
|
+
def tree(self) -> str:
|
|
416
|
+
"""The UI hierarchy XML.
|
|
417
|
+
|
|
418
|
+
A WebView's content arrives a moment after the WebView itself, so while a WebView is still empty,
|
|
419
|
+
wait for the screen to change.
|
|
420
|
+
"""
|
|
421
|
+
xml = self._agent("/tree")
|
|
422
|
+
deadline = time.monotonic() + SETTLE
|
|
423
|
+
while has_empty_webview(xml) and time.monotonic() < deadline:
|
|
424
|
+
self.wait_change(deadline - time.monotonic())
|
|
425
|
+
xml = self._agent("/tree")
|
|
426
|
+
return xml
|
|
427
|
+
|
|
428
|
+
def screen(self) -> Screen:
|
|
429
|
+
"""What's on the screen now."""
|
|
430
|
+
xml = self.tree()
|
|
431
|
+
root = ET.fromstring(xml)
|
|
432
|
+
w, h = self.size(int(root.get("rotation", "0")))
|
|
433
|
+
return Screen(width=w, height=h, elements=tuple(parse_hierarchy(xml, w, h)),
|
|
434
|
+
keyboard_visible=root.get("ime") == "true", keyboard_top=int(root.get("ime-top", "0")))
|
|
435
|
+
|
|
436
|
+
def screenshot(self, path: Path) -> None:
|
|
437
|
+
"""Save a PNG of the screen."""
|
|
438
|
+
path.write_bytes(run_bytes([self.adb, "-s", self.serial, "exec-out", "screencap", "-p"]))
|
|
439
|
+
|
|
440
|
+
# --- touch & keys ------------------------------------------------------------
|
|
441
|
+
def tap(self, x: int, y: int) -> None:
|
|
442
|
+
"""Tap a point."""
|
|
443
|
+
self.sh(f"input tap {x} {y}")
|
|
444
|
+
|
|
445
|
+
def double_tap(self, x: int, y: int) -> None:
|
|
446
|
+
"""Double-tap a point."""
|
|
447
|
+
# One shell call, so the gap between the taps is the sleep and not adb latency.
|
|
448
|
+
self.sh(f"input tap {x} {y}; sleep {DOUBLE_TAP_GAP}; input tap {x} {y}")
|
|
449
|
+
|
|
450
|
+
def long_press(self, x: int, y: int, seconds: float = 1.2) -> None:
|
|
451
|
+
"""Press and hold a point."""
|
|
452
|
+
self.sh(f"input swipe {x} {y} {x} {y} {int(seconds * 1000)}")
|
|
453
|
+
|
|
454
|
+
def drag(self, x1: int, y1: int, x2: int, y2: int) -> None:
|
|
455
|
+
"""Press, move, hold still, lift."""
|
|
456
|
+
# Press, move in steps, hold still, lift: the content stops where the finger stops. A plain
|
|
457
|
+
# `input swipe` lifts while moving, so the content flings on and a scroll lands anywhere.
|
|
458
|
+
steps = [(x1 + (x2 - x1) * i // DRAG_STEPS, y1 + (y2 - y1) * i // DRAG_STEPS)
|
|
459
|
+
for i in range(1, DRAG_STEPS + 1)]
|
|
460
|
+
moves = [f"input motionevent MOVE {x} {y}" for x, y in steps]
|
|
461
|
+
self.sh("; ".join([f"input motionevent DOWN {x1} {y1}", *moves, f"sleep {DRAG_HOLD}",
|
|
462
|
+
f"input motionevent UP {x2} {y2}"]))
|
|
463
|
+
|
|
464
|
+
def type_text(self, text: str, at: Point | None = None) -> None:
|
|
465
|
+
"""Type into the focused field, or first focus the field at `at`."""
|
|
466
|
+
if not text.isascii():
|
|
467
|
+
raise DeviceError("Android `input text` only supports ASCII characters")
|
|
468
|
+
if at: # focus the field, then wait until it has focus and the keyboard is up
|
|
469
|
+
self.tap(*at)
|
|
470
|
+
self._wait_for_typing()
|
|
471
|
+
# `input text` needs %s for spaces; newlines become Enter presses.
|
|
472
|
+
for i, line in enumerate(text.split("\n")):
|
|
473
|
+
if i:
|
|
474
|
+
self.key("enter")
|
|
475
|
+
if line:
|
|
476
|
+
self.sh("input text " + shlex.quote(line.replace("%", r"\%").replace(" ", "%s")))
|
|
477
|
+
|
|
478
|
+
def clear_text(self, element: Element) -> None:
|
|
479
|
+
"""Erase a text field: put the cursor after its text, then delete exactly what is there."""
|
|
480
|
+
self.tap(*element.end)
|
|
481
|
+
self._wait_for_typing()
|
|
482
|
+
if element.value:
|
|
483
|
+
self.sh("input keyevent 123 " + " ".join(["67"] * len(element.value)))
|
|
484
|
+
|
|
485
|
+
def key(self, name: str) -> None:
|
|
486
|
+
"""Press a named key, or an Android key code given as a number."""
|
|
487
|
+
code = KEYCODES.get(name)
|
|
488
|
+
if code is None and not name.isdigit():
|
|
489
|
+
raise DeviceError(f"Unknown key '{name}'. Known: {', '.join(sorted(KEYCODES))}, or a keycode number")
|
|
490
|
+
self.sh(f"input keyevent {code if code is not None else name}")
|
|
491
|
+
|
|
492
|
+
def back(self) -> None:
|
|
493
|
+
"""Press Back."""
|
|
494
|
+
self.key("back")
|
|
495
|
+
|
|
496
|
+
def home(self) -> None:
|
|
497
|
+
"""Press Home."""
|
|
498
|
+
self.key("home")
|
|
499
|
+
|
|
500
|
+
def hide_keyboard(self) -> None:
|
|
501
|
+
"""Close the keyboard with Back, if it's up."""
|
|
502
|
+
# Back closes the keyboard, but with no keyboard it leaves the screen: check right before.
|
|
503
|
+
if ET.fromstring(self._agent("/tree")).get("ime") == "true":
|
|
504
|
+
self.key("back")
|
|
505
|
+
|
|
506
|
+
# --- device ------------------------------------------------------------------
|
|
507
|
+
def rotate(self, orientation: Orientation) -> None:
|
|
508
|
+
"""Rotate the screen; auto-rotate and the orientation are put back on close."""
|
|
509
|
+
if "rotation" not in self._restore: # rotating needs auto-rotate off; close() puts both back
|
|
510
|
+
self._restore["rotation"] = (f"{self._setting('system', 'user_rotation')}; "
|
|
511
|
+
f"{self._setting('system', 'accelerometer_rotation')}")
|
|
512
|
+
self.sh("settings put system accelerometer_rotation 0")
|
|
513
|
+
self.sh(f"settings put system user_rotation {ROTATIONS[orientation]}")
|
|
514
|
+
|
|
515
|
+
def _setting(self, namespace: str, key: str) -> str:
|
|
516
|
+
"""The shell command that puts an Android setting back to its current value."""
|
|
517
|
+
value = self.sh(f"settings get {namespace} {key}", check=False).strip()
|
|
518
|
+
if value in ("", "null"):
|
|
519
|
+
return f"settings delete {namespace} {key}"
|
|
520
|
+
return f"settings put {namespace} {key} {value}"
|
|
521
|
+
|
|
522
|
+
def set_location(self, latitude: float, longitude: float) -> None:
|
|
523
|
+
"""Set the emulator's GPS location."""
|
|
524
|
+
if not self.serial.startswith("emulator-"):
|
|
525
|
+
raise DeviceError("Setting location is only supported on the Android emulator")
|
|
526
|
+
run([self.adb, "-s", self.serial, "emu", "geo", "fix", str(longitude), str(latitude)])
|
|
527
|
+
|
|
528
|
+
def open_url(self, url: str) -> None:
|
|
529
|
+
"""Open a deep link or URL."""
|
|
530
|
+
self.sh(f"am start -W -a android.intent.action.VIEW -d {shlex.quote(url)}")
|
|
531
|
+
|
|
532
|
+
def dark_mode(self, on: bool) -> None:
|
|
533
|
+
"""Switch dark mode; the previous setting is put back on close."""
|
|
534
|
+
if "dark_mode" not in self._restore:
|
|
535
|
+
now = self.sh("cmd uimode night", check=False).strip().removeprefix("Night mode: ")
|
|
536
|
+
if now not in ("yes", "no", "auto"):
|
|
537
|
+
raise DeviceError(f"Can't read the device's dark mode setting to restore it later (got {now!r})")
|
|
538
|
+
self._restore["dark_mode"] = f"cmd uimode night {now}"
|
|
539
|
+
self.sh(f"cmd uimode night {'yes' if on else 'no'}")
|
|
540
|
+
|
|
541
|
+
def grant(self, permission: str) -> None:
|
|
542
|
+
"""Grant the app a runtime permission, by its full name."""
|
|
543
|
+
if not permission.startswith("android.permission."):
|
|
544
|
+
raise DeviceError(f"'{permission}': give the full Android permission name, "
|
|
545
|
+
f"e.g. android.permission.{permission.upper()}")
|
|
546
|
+
self.sh(f"pm grant {self.app_id} {permission}")
|
|
547
|
+
|
|
548
|
+
def network(self, on: bool) -> None:
|
|
549
|
+
"""Switch Wi-Fi and mobile data; their previous state is put back on close."""
|
|
550
|
+
if "network" not in self._restore:
|
|
551
|
+
wifi = self.sh("settings get global wifi_on", check=False).strip() not in ("0", "")
|
|
552
|
+
data = self.sh("settings get global mobile_data", check=False).strip() == "1"
|
|
553
|
+
self._restore["network"] = (f"svc wifi {'enable' if wifi else 'disable'}; "
|
|
554
|
+
f"svc data {'enable' if data else 'disable'}")
|
|
555
|
+
state = "enable" if on else "disable"
|
|
556
|
+
self.sh(f"svc wifi {state}; svc data {state}")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!-- jevtest Android agent: an instrumentation that serves the screen's accessibility tree over HTTP. -->
|
|
3
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
4
|
+
package="dev.jevtest.agent"
|
|
5
|
+
android:versionCode="1"
|
|
6
|
+
android:versionName="1">
|
|
7
|
+
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
|
|
8
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
9
|
+
<application android:label="jevtest agent" android:hasCode="true" />
|
|
10
|
+
<instrumentation
|
|
11
|
+
android:name=".Agent"
|
|
12
|
+
android:targetPackage="dev.jevtest.agent"
|
|
13
|
+
android:label="jevtest agent" />
|
|
14
|
+
</manifest>
|