android-driver 0.0.1__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.
- android_driver/__init__.py +3 -0
- android_driver/actions.py +209 -0
- android_driver/adb.py +355 -0
- android_driver/build.py +81 -0
- android_driver/config.py +211 -0
- android_driver/drivers/__init__.py +22 -0
- android_driver/drivers/adb_driver.py +104 -0
- android_driver/drivers/base.py +146 -0
- android_driver/drivers/factory.py +29 -0
- android_driver/drivers/u2_driver.py +100 -0
- android_driver/emulator.py +277 -0
- android_driver/expect.py +190 -0
- android_driver/log.py +16 -0
- android_driver/recipes.py +547 -0
- android_driver/record.py +112 -0
- android_driver/run.py +292 -0
- android_driver/scan.py +155 -0
- android_driver/server.py +777 -0
- android_driver/session.py +144 -0
- android_driver/ui.py +261 -0
- android_driver-0.0.1.dist-info/METADATA +270 -0
- android_driver-0.0.1.dist-info/RECORD +25 -0
- android_driver-0.0.1.dist-info/WHEEL +4 -0
- android_driver-0.0.1.dist-info/entry_points.txt +2 -0
- android_driver-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""AVD lifecycle and snapshots — the determinism layer.
|
|
2
|
+
|
|
3
|
+
Snapshots are the reason this package exists. Reinstalling an app and walking it
|
|
4
|
+
back to a known screen costs 30-90 seconds; loading a snapshot costs 2-5. For an
|
|
5
|
+
agent doing bug-repro-by-variation, that difference is the difference between
|
|
6
|
+
exploring three hypotheses and exploring thirty.
|
|
7
|
+
|
|
8
|
+
The emulator console is reached through `adb emu <cmd>`, which handles the
|
|
9
|
+
console auth token for us — no ~/.emulator_console_auth_token juggling.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .adb import AdbError, list_devices, shell
|
|
20
|
+
from .adb import _adb as _adb_raw
|
|
21
|
+
from .log import log
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EmulatorError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sdk_root() -> Path:
|
|
29
|
+
"""Locate the Android SDK. Honours ANDROID_HOME / ANDROID_SDK_ROOT, then guesses."""
|
|
30
|
+
for var in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
|
|
31
|
+
value = os.environ.get(var)
|
|
32
|
+
if value and Path(value).is_dir():
|
|
33
|
+
return Path(value)
|
|
34
|
+
for guess in (
|
|
35
|
+
Path.home() / "Library/Android/sdk", # macOS
|
|
36
|
+
Path.home() / "Android/Sdk", # Linux
|
|
37
|
+
Path.home() / "AppData/Local/Android/Sdk", # Windows
|
|
38
|
+
):
|
|
39
|
+
if guess.is_dir():
|
|
40
|
+
return guess
|
|
41
|
+
raise EmulatorError(
|
|
42
|
+
"Android SDK not found. Set ANDROID_HOME to your SDK directory "
|
|
43
|
+
"(e.g. ~/Library/Android/sdk on macOS)."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def emulator_binary() -> Path:
|
|
48
|
+
path = sdk_root() / "emulator" / "emulator"
|
|
49
|
+
if not path.is_file():
|
|
50
|
+
raise EmulatorError(
|
|
51
|
+
f"emulator binary not found at {path}. Install it via Android Studio's "
|
|
52
|
+
"SDK Manager, or `sdkmanager emulator`."
|
|
53
|
+
)
|
|
54
|
+
return path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def list_avds() -> list[str]:
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
[str(emulator_binary()), "-list-avds"], text=True, capture_output=True, check=False, timeout=60
|
|
60
|
+
)
|
|
61
|
+
if result.returncode != 0:
|
|
62
|
+
raise EmulatorError(f"emulator -list-avds failed: {result.stderr.strip()}")
|
|
63
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def avd_name_of(serial: str) -> str | None:
|
|
67
|
+
"""Ask a running emulator which AVD it is. Returns None for physical devices."""
|
|
68
|
+
if not serial.startswith("emulator-"):
|
|
69
|
+
return None
|
|
70
|
+
result = _adb_raw(["-s", serial, "emu", "avd", "name"], check=False, timeout=15)
|
|
71
|
+
for line in result.stdout.splitlines():
|
|
72
|
+
line = line.strip()
|
|
73
|
+
if line and line != "OK":
|
|
74
|
+
return line
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def running_emulators() -> dict[str, str | None]:
|
|
79
|
+
"""Map serial → AVD name for every attached emulator."""
|
|
80
|
+
return {
|
|
81
|
+
d["serial"]: avd_name_of(d["serial"])
|
|
82
|
+
for d in list_devices()
|
|
83
|
+
if d["serial"].startswith("emulator-")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_booted(serial: str) -> bool:
|
|
88
|
+
"""True once the framework is up AND the boot animation has finished.
|
|
89
|
+
|
|
90
|
+
`sys.boot_completed` alone is not enough: it flips while the boot animation is
|
|
91
|
+
still playing, and UI automation against that window fails in ways that look
|
|
92
|
+
like flaky selectors rather than "the device is not ready yet".
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
completed = shell(serial, "getprop", "sys.boot_completed", check=False, timeout=15).strip()
|
|
96
|
+
bootanim = shell(serial, "getprop", "init.svc.bootanim", check=False, timeout=15).strip()
|
|
97
|
+
except AdbError:
|
|
98
|
+
return False
|
|
99
|
+
return completed == "1" and bootanim == "stopped"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def wait_for_boot(serial: str, timeout_s: int = 300, poll_s: float = 2.0) -> dict:
|
|
103
|
+
"""Block until `serial` has fully booted."""
|
|
104
|
+
deadline = time.monotonic() + timeout_s
|
|
105
|
+
started = time.monotonic()
|
|
106
|
+
while time.monotonic() < deadline:
|
|
107
|
+
if is_booted(serial):
|
|
108
|
+
elapsed = round(time.monotonic() - started, 1)
|
|
109
|
+
log("emulator", f"{serial} booted in {elapsed}s")
|
|
110
|
+
return {"ok": True, "serial": serial, "boot_seconds": elapsed}
|
|
111
|
+
time.sleep(poll_s)
|
|
112
|
+
return {"ok": False, "serial": serial, "error": f"boot timeout after {timeout_s}s"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def start(
|
|
116
|
+
avd: str,
|
|
117
|
+
*,
|
|
118
|
+
headless: bool = False,
|
|
119
|
+
cold_boot: bool = False,
|
|
120
|
+
wipe_data: bool = False,
|
|
121
|
+
snapshot: str | None = None,
|
|
122
|
+
writable_system: bool = False,
|
|
123
|
+
extra_args: list[str] | None = None,
|
|
124
|
+
boot_timeout_s: int = 300,
|
|
125
|
+
) -> dict:
|
|
126
|
+
"""Boot an AVD and wait for it to be usable. Returns its serial.
|
|
127
|
+
|
|
128
|
+
If the AVD is already running, returns the existing serial instead of booting a
|
|
129
|
+
second copy — an agent that calls `start_emulator` defensively at the top of
|
|
130
|
+
every run should not end up with four emulators fighting over the same ports.
|
|
131
|
+
"""
|
|
132
|
+
available = list_avds()
|
|
133
|
+
if avd not in available:
|
|
134
|
+
raise EmulatorError(f"unknown AVD {avd!r}. Available: {available}")
|
|
135
|
+
|
|
136
|
+
already = {name: serial for serial, name in running_emulators().items() if name}
|
|
137
|
+
if avd in already:
|
|
138
|
+
serial = already[avd]
|
|
139
|
+
log("emulator", f"{avd} already running as {serial}; reusing")
|
|
140
|
+
return {"ok": True, "serial": serial, "avd": avd, "reused": True}
|
|
141
|
+
|
|
142
|
+
before = {d["serial"] for d in list_devices()}
|
|
143
|
+
cmd = [str(emulator_binary()), "-avd", avd]
|
|
144
|
+
if headless:
|
|
145
|
+
cmd += ["-no-window", "-no-audio"]
|
|
146
|
+
if cold_boot:
|
|
147
|
+
cmd += ["-no-snapshot-load"]
|
|
148
|
+
if wipe_data:
|
|
149
|
+
cmd += ["-wipe-data"]
|
|
150
|
+
if snapshot:
|
|
151
|
+
cmd += ["-snapshot", snapshot]
|
|
152
|
+
if writable_system:
|
|
153
|
+
cmd += ["-writable-system"]
|
|
154
|
+
cmd += extra_args or []
|
|
155
|
+
|
|
156
|
+
log("emulator", f"launching: {' '.join(cmd)}")
|
|
157
|
+
# Detached: the emulator outlives this call and must not die with the MCP
|
|
158
|
+
# server's process group, nor block on a pipe nobody reads.
|
|
159
|
+
subprocess.Popen(
|
|
160
|
+
cmd,
|
|
161
|
+
stdout=subprocess.DEVNULL,
|
|
162
|
+
stderr=subprocess.DEVNULL,
|
|
163
|
+
stdin=subprocess.DEVNULL,
|
|
164
|
+
start_new_session=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
deadline = time.monotonic() + boot_timeout_s
|
|
168
|
+
serial: str | None = None
|
|
169
|
+
while time.monotonic() < deadline and serial is None:
|
|
170
|
+
time.sleep(2.0)
|
|
171
|
+
for candidate in {d["serial"] for d in list_devices()} - before:
|
|
172
|
+
if candidate.startswith("emulator-"):
|
|
173
|
+
serial = candidate
|
|
174
|
+
break
|
|
175
|
+
if serial is None:
|
|
176
|
+
raise EmulatorError(
|
|
177
|
+
f"{avd} did not appear in `adb devices` within {boot_timeout_s}s. "
|
|
178
|
+
f"Try launching it by hand to see the error: {' '.join(cmd)}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
remaining = max(30, int(deadline - time.monotonic()))
|
|
182
|
+
boot = wait_for_boot(serial, timeout_s=remaining)
|
|
183
|
+
return {**boot, "avd": avd, "serial": serial, "reused": False}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def stop(serial: str) -> dict:
|
|
187
|
+
"""Shut down a running emulator via its console."""
|
|
188
|
+
result = _adb_raw(["-s", serial, "emu", "kill"], check=False, timeout=30)
|
|
189
|
+
combined = result.stdout + result.stderr
|
|
190
|
+
if result.returncode != 0 and "OK" not in combined:
|
|
191
|
+
raise EmulatorError(f"could not stop {serial}: {combined.strip()}")
|
|
192
|
+
for _ in range(15):
|
|
193
|
+
if serial not in {d["serial"] for d in list_devices()}:
|
|
194
|
+
return {"ok": True, "serial": serial}
|
|
195
|
+
time.sleep(1.0)
|
|
196
|
+
return {"ok": False, "serial": serial, "error": "still attached 15s after `emu kill`"}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── snapshots ─────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _snapshot_cmd(serial: str, *args: str) -> str:
|
|
203
|
+
if not serial.startswith("emulator-"):
|
|
204
|
+
raise EmulatorError(f"{serial} is not an emulator — snapshots are emulator-only.")
|
|
205
|
+
result = _adb_raw(["-s", serial, "emu", "avd", "snapshot", *args], check=False, timeout=180)
|
|
206
|
+
combined = (result.stdout + result.stderr).strip()
|
|
207
|
+
# The console answers "KO: <reason>" on its own line. Substring-matching "KO"
|
|
208
|
+
# against the whole blob would also fire on a snapshot named e.g. "OKO".
|
|
209
|
+
failed = any(line.strip().startswith("KO") for line in combined.splitlines())
|
|
210
|
+
if result.returncode != 0 or failed:
|
|
211
|
+
raise EmulatorError(f"snapshot {' '.join(args)} on {serial} failed: {combined}")
|
|
212
|
+
return combined
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def snapshot_save(serial: str, name: str) -> dict:
|
|
216
|
+
"""Freeze the device's exact current state under `name`.
|
|
217
|
+
|
|
218
|
+
Save right after your app is installed, permissions granted, and you are on the
|
|
219
|
+
screen a test starts from. Every later `snapshot_load(name)` returns to exactly
|
|
220
|
+
that point, so a repro attempt starts from identical state every time.
|
|
221
|
+
"""
|
|
222
|
+
started = time.monotonic()
|
|
223
|
+
_snapshot_cmd(serial, "save", name)
|
|
224
|
+
return {"ok": True, "serial": serial, "snapshot": name, "seconds": round(time.monotonic() - started, 1)}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def snapshot_load(serial: str, name: str, settle_timeout_s: int = 120) -> dict:
|
|
228
|
+
"""Restore `name`, then wait until the device can actually be driven again.
|
|
229
|
+
|
|
230
|
+
The restore swaps the running system out wholesale. For a second or two after
|
|
231
|
+
the console returns, `adb` reports the device `offline` and the uiautomator
|
|
232
|
+
server is a process from the restored image rather than the one we were
|
|
233
|
+
talking to. Returning at that point hands the caller a device whose next call
|
|
234
|
+
dies with `RemoteDisconnected` or `device offline` — which reads as a broken
|
|
235
|
+
app, three steps into a flow, and sends you debugging the wrong thing.
|
|
236
|
+
"""
|
|
237
|
+
started = time.monotonic()
|
|
238
|
+
_snapshot_cmd(serial, "load", name)
|
|
239
|
+
# Let the restore drop the device before asking whether it is back: polling
|
|
240
|
+
# immediately can still observe the pre-restore connection and pass at once.
|
|
241
|
+
time.sleep(1.0)
|
|
242
|
+
ready = wait_for_boot(serial, timeout_s=settle_timeout_s)
|
|
243
|
+
seconds = round(time.monotonic() - started, 1)
|
|
244
|
+
if not ready.get("ok"):
|
|
245
|
+
raise EmulatorError(
|
|
246
|
+
f"{serial} did not come back within {settle_timeout_s}s after loading snapshot {name!r}"
|
|
247
|
+
)
|
|
248
|
+
return {"ok": True, "serial": serial, "snapshot": name, "seconds": seconds}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def snapshot_delete(serial: str, name: str) -> dict:
|
|
252
|
+
_snapshot_cmd(serial, "delete", name)
|
|
253
|
+
return {"ok": True, "serial": serial, "snapshot": name}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def snapshot_list(serial: str) -> list[str]:
|
|
257
|
+
"""Snapshot names known to the running emulator.
|
|
258
|
+
|
|
259
|
+
The console prints a table whose ID column is `--` on current emulator builds
|
|
260
|
+
and an integer on older ones, so both are accepted:
|
|
261
|
+
|
|
262
|
+
ID TAG VM SIZE DATE VM CLOCK
|
|
263
|
+
-- default_boot 458M 2026-08-26 20:26:32 211:41:03.770
|
|
264
|
+
"""
|
|
265
|
+
raw = _snapshot_cmd(serial, "list")
|
|
266
|
+
names: list[str] = []
|
|
267
|
+
for raw_line in raw.splitlines():
|
|
268
|
+
line = raw_line.strip()
|
|
269
|
+
if not line or line == "OK" or line.startswith(("ID", "List of snapshots", "There are no")):
|
|
270
|
+
continue
|
|
271
|
+
parts = line.split()
|
|
272
|
+
if len(parts) < 2:
|
|
273
|
+
continue
|
|
274
|
+
is_row = parts[0].isdigit() or set(parts[0]) == {"-"}
|
|
275
|
+
if is_row and set(parts[1]) != {"-"}:
|
|
276
|
+
names.append(parts[1])
|
|
277
|
+
return names
|
android_driver/expect.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Assertions — the verbs that turn *driving* an app into *testing* it.
|
|
2
|
+
|
|
3
|
+
Everything here returns `{"ok": bool, "passed": bool, ...}`. `ok` mirrors
|
|
4
|
+
`passed`, so an agent can branch on the same field it uses everywhere else, and
|
|
5
|
+
a failing assertion is a normal result rather than an exception.
|
|
6
|
+
|
|
7
|
+
Every failure carries enough context to act on without a follow-up call: a
|
|
8
|
+
missing element comes back with the screen index that *was* there, a log
|
|
9
|
+
assertion with the tail it searched. That is deliberate — the round trip an
|
|
10
|
+
agent would otherwise make is the expensive part.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import adb
|
|
20
|
+
from .config import Config
|
|
21
|
+
from .session import Session
|
|
22
|
+
|
|
23
|
+
# Patterns that mean "the app died", in the order we report them.
|
|
24
|
+
CRASH_PATTERNS = (
|
|
25
|
+
("fatal_exception", re.compile(r"FATAL EXCEPTION")),
|
|
26
|
+
("anr", re.compile(r"\bANR in ([\w.]+)")),
|
|
27
|
+
("native_crash", re.compile(r"signal \d+ \(SIG\w+\)")),
|
|
28
|
+
("tombstone", re.compile(r"Tombstone written to:")),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# How many lines around a crash marker we keep as the excerpt. The lookback
|
|
32
|
+
# matters for native crashes: `signal 11 (SIGSEGV)` says nothing about which
|
|
33
|
+
# process died — the `pid: ..., name: <pkg>` line above it does.
|
|
34
|
+
CRASH_CONTEXT_LINES = 25
|
|
35
|
+
CRASH_LOOKBACK_LINES = 6
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _active(selector: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
return {k: v for k, v in selector.items() if v is not None and k != "index"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fail(reason: str, **fields: Any) -> dict[str, Any]:
|
|
43
|
+
return {"ok": False, "passed": False, "error": reason, **fields}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pass(**fields: Any) -> dict[str, Any]:
|
|
47
|
+
return {"ok": True, "passed": True, **fields}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def visible(session: Session, timeout_s: float = 10.0, **selector: Any) -> dict[str, Any]:
|
|
51
|
+
"""Poll until an element matching `selector` is on screen."""
|
|
52
|
+
active = _active(selector)
|
|
53
|
+
if not active:
|
|
54
|
+
raise ValueError("expect_visible needs a selector: ref / text / contains / desc / id / cls")
|
|
55
|
+
started = time.monotonic()
|
|
56
|
+
try:
|
|
57
|
+
element = session.wait_for(timeout_s, **selector)
|
|
58
|
+
except LookupError:
|
|
59
|
+
return _fail(
|
|
60
|
+
f"nothing matching {active} appeared within {timeout_s}s",
|
|
61
|
+
selector=active,
|
|
62
|
+
waited_s=round(time.monotonic() - started, 2),
|
|
63
|
+
screen=session.screen_text(),
|
|
64
|
+
)
|
|
65
|
+
return _pass(
|
|
66
|
+
selector=active,
|
|
67
|
+
found=element.to_dict(),
|
|
68
|
+
waited_s=round(time.monotonic() - started, 2),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def gone(session: Session, timeout_s: float = 10.0, **selector: Any) -> dict[str, Any]:
|
|
73
|
+
"""Poll until nothing matches `selector` — for dismissals and loading spinners."""
|
|
74
|
+
active = _active(selector)
|
|
75
|
+
if not active:
|
|
76
|
+
raise ValueError("expect_gone needs a selector: ref / text / contains / desc / id / cls")
|
|
77
|
+
started = time.monotonic()
|
|
78
|
+
try:
|
|
79
|
+
session.wait_until_gone(timeout_s, **selector)
|
|
80
|
+
except TimeoutError as e:
|
|
81
|
+
return _fail(
|
|
82
|
+
str(e),
|
|
83
|
+
selector=active,
|
|
84
|
+
waited_s=round(time.monotonic() - started, 2),
|
|
85
|
+
screen=session.screen_text(),
|
|
86
|
+
)
|
|
87
|
+
return _pass(selector=active, waited_s=round(time.monotonic() - started, 2))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def log_matches(
|
|
91
|
+
session: Session,
|
|
92
|
+
cfg: Config,
|
|
93
|
+
pattern: str,
|
|
94
|
+
timeout_s: float = 30.0,
|
|
95
|
+
poll_s: float = 1.0,
|
|
96
|
+
only_app: bool = True,
|
|
97
|
+
level: str | None = None,
|
|
98
|
+
lines: int = 4000,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Poll logcat until `pattern` (a regex) shows up.
|
|
101
|
+
|
|
102
|
+
Clear the buffer first — `logcat_clear`, or just start a run, which does it
|
|
103
|
+
for you — or a match left over from a previous attempt will pass this.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
re.compile(pattern)
|
|
107
|
+
except re.error as e:
|
|
108
|
+
raise ValueError(f"{pattern!r} is not a valid regex: {e}") from e
|
|
109
|
+
|
|
110
|
+
pkg = cfg.app.package if (only_app and cfg.app.package) else None
|
|
111
|
+
started = time.monotonic()
|
|
112
|
+
deadline = started + timeout_s
|
|
113
|
+
found: list[str] = []
|
|
114
|
+
while True:
|
|
115
|
+
found = adb.logcat_dump(session.serial, lines=lines, pkg=pkg, pattern=pattern, level=level)
|
|
116
|
+
if found:
|
|
117
|
+
return _pass(
|
|
118
|
+
pattern=pattern,
|
|
119
|
+
count=len(found),
|
|
120
|
+
matches=found[-10:],
|
|
121
|
+
waited_s=round(time.monotonic() - started, 2),
|
|
122
|
+
)
|
|
123
|
+
if time.monotonic() >= deadline:
|
|
124
|
+
break
|
|
125
|
+
time.sleep(poll_s)
|
|
126
|
+
|
|
127
|
+
tail = adb.logcat_dump(session.serial, lines=40, pkg=pkg, level=level)
|
|
128
|
+
return _fail(
|
|
129
|
+
f"no logcat line matched {pattern!r} within {timeout_s}s",
|
|
130
|
+
pattern=pattern,
|
|
131
|
+
waited_s=round(time.monotonic() - started, 2),
|
|
132
|
+
tail=tail[-25:],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def scan_crashes(lines: list[str], pkg: str | None = None) -> list[dict[str, Any]]:
|
|
137
|
+
"""Pull crash records out of raw logcat lines.
|
|
138
|
+
|
|
139
|
+
When `pkg` is given, a Java crash is only reported if its `Process:` line
|
|
140
|
+
names that package — otherwise every unrelated system crash on the device
|
|
141
|
+
would fail the assertion, which trains an agent to ignore it.
|
|
142
|
+
"""
|
|
143
|
+
crashes: list[dict[str, Any]] = []
|
|
144
|
+
last_index = -CRASH_CONTEXT_LINES
|
|
145
|
+
for i, line in enumerate(lines):
|
|
146
|
+
for kind, rx in CRASH_PATTERNS:
|
|
147
|
+
match = rx.search(line)
|
|
148
|
+
if not match:
|
|
149
|
+
continue
|
|
150
|
+
excerpt = lines[max(0, i - CRASH_LOOKBACK_LINES) : i + CRASH_CONTEXT_LINES]
|
|
151
|
+
if pkg:
|
|
152
|
+
named = pkg in "\n".join(excerpt)
|
|
153
|
+
if kind == "anr" and match.lastindex:
|
|
154
|
+
named = match.group(1) == pkg
|
|
155
|
+
if not named:
|
|
156
|
+
continue
|
|
157
|
+
# One dying process writes several markers (signal, then tombstone);
|
|
158
|
+
# reporting them as separate crashes would overstate what happened.
|
|
159
|
+
if i - last_index < CRASH_CONTEXT_LINES and crashes:
|
|
160
|
+
break
|
|
161
|
+
last_index = i
|
|
162
|
+
crashes.append({"kind": kind, "line": line.strip(), "excerpt": excerpt})
|
|
163
|
+
break
|
|
164
|
+
return crashes
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def no_crash(
|
|
168
|
+
session: Session,
|
|
169
|
+
cfg: Config,
|
|
170
|
+
pkg: str | None = None,
|
|
171
|
+
lines: int = 4000,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Assert nothing in the log says the app died.
|
|
174
|
+
|
|
175
|
+
Reads the dedicated `crash` buffer as well as `main`: a native abort or a
|
|
176
|
+
tombstone never reaches `main` at all, so a check that only reads `main`
|
|
177
|
+
quietly passes on the worst class of failure there is.
|
|
178
|
+
"""
|
|
179
|
+
target = pkg or cfg.app.package
|
|
180
|
+
log_lines = adb.logcat_dump(session.serial, lines=lines, buffers="main,crash")
|
|
181
|
+
crashes = scan_crashes(log_lines, target)
|
|
182
|
+
if crashes:
|
|
183
|
+
first = crashes[0]
|
|
184
|
+
return _fail(
|
|
185
|
+
f"{len(crashes)} crash record(s) in the log; first is {first['kind']}",
|
|
186
|
+
pkg=target,
|
|
187
|
+
crashes=[{"kind": c["kind"], "line": c["line"]} for c in crashes],
|
|
188
|
+
excerpt=first["excerpt"],
|
|
189
|
+
)
|
|
190
|
+
return _pass(pkg=target, scanned_lines=len(log_lines))
|
android_driver/log.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Diagnostic logging.
|
|
2
|
+
|
|
3
|
+
stdout is reserved for the MCP JSON-RPC frame under the stdio transport, so
|
|
4
|
+
EVERY diagnostic write in this package goes to stderr. Corrupting stdout with a
|
|
5
|
+
stray print() breaks the protocol in a way that is very hard to debug from the
|
|
6
|
+
client side.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log(component: str, msg: str) -> None:
|
|
16
|
+
print(f"[{time.strftime('%H:%M:%S')}] [{component}] {msg}", file=sys.stderr, flush=True)
|