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