android-driver 0.0.1__tar.gz
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-0.0.1/.gitignore +11 -0
- android_driver-0.0.1/CONTRIBUTING.md +92 -0
- android_driver-0.0.1/LICENSE +21 -0
- android_driver-0.0.1/PKG-INFO +270 -0
- android_driver-0.0.1/README.md +240 -0
- android_driver-0.0.1/docs/agent-guide.md +66 -0
- android_driver-0.0.1/docs/configuration.md +135 -0
- android_driver-0.0.1/docs/installation.md +117 -0
- android_driver-0.0.1/docs/recipes.md +153 -0
- android_driver-0.0.1/docs/roadmap.md +57 -0
- android_driver-0.0.1/examples/README.md +36 -0
- android_driver-0.0.1/examples/compose-app/.android-driver.yaml +77 -0
- android_driver-0.0.1/examples/view-app/.android-driver.yaml +61 -0
- android_driver-0.0.1/pyproject.toml +66 -0
- android_driver-0.0.1/src/android_driver/__init__.py +3 -0
- android_driver-0.0.1/src/android_driver/actions.py +209 -0
- android_driver-0.0.1/src/android_driver/adb.py +355 -0
- android_driver-0.0.1/src/android_driver/build.py +81 -0
- android_driver-0.0.1/src/android_driver/config.py +211 -0
- android_driver-0.0.1/src/android_driver/drivers/__init__.py +22 -0
- android_driver-0.0.1/src/android_driver/drivers/adb_driver.py +104 -0
- android_driver-0.0.1/src/android_driver/drivers/base.py +146 -0
- android_driver-0.0.1/src/android_driver/drivers/factory.py +29 -0
- android_driver-0.0.1/src/android_driver/drivers/u2_driver.py +100 -0
- android_driver-0.0.1/src/android_driver/emulator.py +277 -0
- android_driver-0.0.1/src/android_driver/expect.py +190 -0
- android_driver-0.0.1/src/android_driver/log.py +16 -0
- android_driver-0.0.1/src/android_driver/recipes.py +547 -0
- android_driver-0.0.1/src/android_driver/record.py +112 -0
- android_driver-0.0.1/src/android_driver/run.py +292 -0
- android_driver-0.0.1/src/android_driver/scan.py +155 -0
- android_driver-0.0.1/src/android_driver/server.py +777 -0
- android_driver-0.0.1/src/android_driver/session.py +144 -0
- android_driver-0.0.1/src/android_driver/ui.py +261 -0
- android_driver-0.0.1/tests/__init__.py +0 -0
- android_driver-0.0.1/tests/conftest.py +107 -0
- android_driver-0.0.1/tests/fixtures/home_screen.xml +8 -0
- android_driver-0.0.1/tests/fixtures/login_screen.xml +15 -0
- android_driver-0.0.1/tests/integration/__init__.py +0 -0
- android_driver-0.0.1/tests/integration/conftest.py +42 -0
- android_driver-0.0.1/tests/integration/test_live.py +192 -0
- android_driver-0.0.1/tests/test_actions.py +85 -0
- android_driver-0.0.1/tests/test_config.py +88 -0
- android_driver-0.0.1/tests/test_expect.py +120 -0
- android_driver-0.0.1/tests/test_packaging.py +86 -0
- android_driver-0.0.1/tests/test_recipes.py +244 -0
- android_driver-0.0.1/tests/test_reload.py +113 -0
- android_driver-0.0.1/tests/test_run.py +103 -0
- android_driver-0.0.1/tests/test_scan.py +109 -0
- android_driver-0.0.1/tests/test_server.py +87 -0
- android_driver-0.0.1/tests/test_session.py +82 -0
- android_driver-0.0.1/tests/test_ui.py +81 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Bug reports, recipes for apps you have driven, and backend fixes are all welcome. The project is
|
|
4
|
+
early, so the shape of things is still open to argument.
|
|
5
|
+
|
|
6
|
+
## Setup
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
git clone https://github.com/earlzdev/android-driver.git
|
|
10
|
+
cd android-driver
|
|
11
|
+
uv sync --extra dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv run pytest # 129 unit tests, no device needed
|
|
16
|
+
uv run ruff check src tests
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The unit suite runs against a fake driver over recorded hierarchy fixtures in `tests/fixtures/`, so
|
|
20
|
+
the whole engine — selector resolution, recipes, assertions, run bundles, config discovery — is
|
|
21
|
+
testable without an emulator. Please keep it that way: a test that needs a device belongs in
|
|
22
|
+
`tests/integration/`.
|
|
23
|
+
|
|
24
|
+
## Working on the server from Claude Code
|
|
25
|
+
|
|
26
|
+
Opening this repo in Claude Code gives you the tools built from your working tree, via a
|
|
27
|
+
project-scoped `.mcp.json`. Edit the source, restart the server, and the change is live.
|
|
28
|
+
|
|
29
|
+
Two things about that setup are load-bearing:
|
|
30
|
+
|
|
31
|
+
- The dev server is named `android-driver-dev`, not `android-driver`. If you also have the plugin
|
|
32
|
+
installed, two servers sharing a name makes it ambiguous which one a tool call reaches.
|
|
33
|
+
- The plugin's own MCP config is a separate file, `mcp-config.json`. A `.mcp.json` at a repo root is
|
|
34
|
+
read as a *project* config, where `${CLAUDE_PLUGIN_ROOT}` does not resolve.
|
|
35
|
+
|
|
36
|
+
**Never launch with `uvx --from <path>`.** uv keys that build cache on `pyproject.toml`'s mtime, so
|
|
37
|
+
editing anything under `src/` leaves the cached wheel in place and the server keeps serving old code
|
|
38
|
+
— silently, with a normal-looking startup. `uv run` re-syncs from source every start.
|
|
39
|
+
`tests/test_packaging.py` will fail if anyone reintroduces it, because nothing else would catch it.
|
|
40
|
+
|
|
41
|
+
## The live suite
|
|
42
|
+
|
|
43
|
+
Needs a booted emulator, and is skipped otherwise:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ANDROID_DRIVER_LIVE=1 uv run pytest tests/integration
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
It drives [FlakyDemo](test_app/), the demo app in this repo. Build and install it first:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd test_app && ./gradlew :app:assembleDebug
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The live suite saves and loads snapshots and can leave the device in an unexpected state, so point it
|
|
56
|
+
at a scratch AVD rather than one you care about.
|
|
57
|
+
|
|
58
|
+
## Testing a change end to end
|
|
59
|
+
|
|
60
|
+
Unit tests cannot see the two things most likely to break for a user: what the *shipped* launcher
|
|
61
|
+
runs, and what a real device does. Both have burned this project before. If you touch packaging,
|
|
62
|
+
`session.py`, `emulator.py` or the drivers, run the loop for real against FlakyDemo — install, smoke,
|
|
63
|
+
and one repro from a snapshot — before opening the PR.
|
|
64
|
+
|
|
65
|
+
## Style
|
|
66
|
+
|
|
67
|
+
- Comments explain **why**, not what. If a line is there because of a device quirk, say which quirk —
|
|
68
|
+
that is the knowledge worth keeping.
|
|
69
|
+
- Errors should say what to do next. This is the standard to match:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
no adb device in state 'device'. Start an emulator with `start_emulator`,
|
|
73
|
+
or check `adb devices` for an unauthorized/offline entry.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- Tool docstrings are read by an agent, not just by you. Write them for the reader who has to decide
|
|
77
|
+
whether this is the right tool.
|
|
78
|
+
- `ruff check` must pass. The project does not enforce `ruff format`.
|
|
79
|
+
|
|
80
|
+
## Adding a tool
|
|
81
|
+
|
|
82
|
+
Tools live in `src/android_driver/server.py` and are thin: they wrap a function from `actions.py` or
|
|
83
|
+
`expect.py` in `_act`, which handles errors, timing, run recording and evidence capture. Put real
|
|
84
|
+
logic in the action layer, so recipes get it too — recipes and hand-driven tools share that path
|
|
85
|
+
deliberately, and a tool that bypasses it will drift.
|
|
86
|
+
|
|
87
|
+
Add the name to `RESERVED_TOOL_NAMES`, and a row to the tool table in `README.md`.
|
|
88
|
+
|
|
89
|
+
## Pull requests
|
|
90
|
+
|
|
91
|
+
Say what broke and how you know it is fixed. A failing test that now passes is the best form of that;
|
|
92
|
+
a run directory from a real device is a good second.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 android-driver contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: android-driver
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: MCP server that turns an Android emulator into a deterministic, agent-drivable test harness.
|
|
5
|
+
Project-URL: Homepage, https://github.com/earlzdev/android-driver
|
|
6
|
+
Project-URL: Repository, https://github.com/earlzdev/android-driver
|
|
7
|
+
Project-URL: Issues, https://github.com/earlzdev/android-driver/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/earlzdev/android-driver#readme
|
|
9
|
+
Author: earlzdev
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: adb,agent,android,emulator,mcp,testing,uiautomator
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: mcp<2,>=1.2
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
25
|
+
Requires-Dist: uiautomator2<4,>=3.2
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# android-driver
|
|
32
|
+
|
|
33
|
+
**Drive an Android emulator as a deterministic test harness — from Claude Code.**
|
|
34
|
+
|
|
35
|
+
[](https://github.com/earlzdev/android-driver/actions/workflows/ci.yml)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
|
|
39
|
+
You ask an agent to reproduce a bug in your Android app. It dumps 80 KB of accessibility XML into its
|
|
40
|
+
own context, taps something that turns out to be the wrong element, and when the bug does not appear
|
|
41
|
+
it cannot repeat what it just did — because the app is now three screens deep in a state nobody
|
|
42
|
+
recorded.
|
|
43
|
+
|
|
44
|
+
android-driver is built for that loop instead: **build → install → drive → assert → reset → repeat.**
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
> reproduce the crash when the bio field goes over 100 characters
|
|
48
|
+
|
|
49
|
+
snapshot_load("clean") 1.9s
|
|
50
|
+
open_settings() ok
|
|
51
|
+
type_text(id=text_field_Bio, text=110 chars) ok
|
|
52
|
+
expect_log("StringIndexOutOfBoundsException") ok — matched
|
|
53
|
+
expect_no_crash() FAILED — 1 crash record
|
|
54
|
+
|
|
55
|
+
runs/20260901-080304-repro-set-06/report.md
|
|
56
|
+
→ java.lang.StringIndexOutOfBoundsException: begin 0, end 120, length 110
|
|
57
|
+
at ...screens.SettingsScreenKt.SettingsScreen$textFields(SettingsScreen.kt:149)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Three attempts from the same snapshot, three identical results, and a directory of evidence to point
|
|
61
|
+
at. That is the whole idea.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
It is a Claude Code plugin: one install brings the tools, a skill that teaches Claude the loop, and
|
|
68
|
+
three slash commands.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
claude plugin marketplace add earlzdev/android-driver
|
|
72
|
+
claude plugin install android-driver@android-driver
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
There is no venv to manage — the plugin builds the Python server from source on demand, and passes
|
|
76
|
+
your project directory to it so your config is found wherever Claude was launched from.
|
|
77
|
+
|
|
78
|
+
**Requirements:** `adb` and the Android SDK's `emulator` on `PATH`, Python ≥ 3.10. For the faster
|
|
79
|
+
uiautomator2 backend run `python -m uiautomator2 init` once per device; without it the server falls
|
|
80
|
+
back to a pure-adb backend that needs nothing installed on the device.
|
|
81
|
+
|
|
82
|
+
Prefer a plain MCP server, or not using Claude Code at all? See
|
|
83
|
+
[docs/installation.md](docs/installation.md).
|
|
84
|
+
|
|
85
|
+
## Quickstart
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
/android-driver:setup
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
It checks your toolchain, finds your `applicationId` and build command, detects whether you are on
|
|
92
|
+
Compose or Views, writes a starter `.android-driver.yaml`, boots an emulator and proves the loop
|
|
93
|
+
works. Then:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
/android-driver:smoke # build, install, walk the main flows, assert nothing broke
|
|
97
|
+
/android-driver:repro <what is broken> # reproduce it from a snapshot and leave evidence
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Or just talk to it — the `android-testing` skill loads automatically when a task involves driving the
|
|
101
|
+
app, so "check that login still works on a fresh install" does the right thing without ceremony.
|
|
102
|
+
|
|
103
|
+
## Why it works this way
|
|
104
|
+
|
|
105
|
+
Four decisions do most of the work.
|
|
106
|
+
|
|
107
|
+
**Snapshots, so a repro is actually reproducible.** `snapshot_save` freezes the emulator's exact
|
|
108
|
+
state; `snapshot_load` restores it and waits until the device is genuinely drivable again — 1.8–2.2s
|
|
109
|
+
measured on a Pixel 7 AVD. Reinstalling and re-navigating costs 30–90s *and drifts a little each
|
|
110
|
+
time*. An agent testing thirty variations of a hypothesis needs the cheap, identical option: the
|
|
111
|
+
difference between two attempts only means something if everything else was the same.
|
|
112
|
+
|
|
113
|
+
**A screen index instead of a wall of XML.** A raw `uiautomator dump` is 50–200 KB per screen — tens
|
|
114
|
+
of thousands of tokens for a model that just wants to know what it can tap. `screen` returns this:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
device=emulator-5554 app=com.example.app/.MainActivity screen=1080x2400 driver=uiautomator2
|
|
118
|
+
#5 [Scroll] id=settings_container (scrollable) @(540,1236)
|
|
119
|
+
#7 [Text] "Settings" id=homepage_title @(235,472)
|
|
120
|
+
#18 [EditText] "" id=text_field_Bio @(540,1018)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Then `tap(ref="#7")`. Two orders of magnitude smaller, and it reads like a menu. The raw tree is
|
|
124
|
+
still there behind `dump_ui_xml` for when you genuinely need it.
|
|
125
|
+
|
|
126
|
+
**Assertions that collect their own evidence.** `expect_visible` polls, so it is safe immediately
|
|
127
|
+
after a tap and will not flake on an animation; when it fails it hands back the screen index that
|
|
128
|
+
*was* there, plus a screenshot and hierarchy dump on disk. `expect_no_crash` reads the `crash` buffer
|
|
129
|
+
as well as `main`, because a native abort never reaches `main` at all. Wrap a sequence in
|
|
130
|
+
`run_start` / `run_end` and you get `runs/<id>/` holding a timeline, a report, the logcat slice for
|
|
131
|
+
exactly that window, and every failure artifact — so an agent cites a directory instead of describing
|
|
132
|
+
what it saw.
|
|
133
|
+
|
|
134
|
+
**Your flows as first-class tools.** The six steps every test starts with — sign in, create an order,
|
|
135
|
+
join a call — go into `.android-driver.yaml` once and become real MCP tools with typed parameters. An
|
|
136
|
+
agent sees `login(email, password)` in its tool list rather than rediscovering the flow from a screen
|
|
137
|
+
dump every session. Recipes run the same code path as the hand-driven tools, so the two cannot drift.
|
|
138
|
+
|
|
139
|
+
<details>
|
|
140
|
+
<summary>Plus the device knowledge that costs an afternoon each to learn</summary>
|
|
141
|
+
|
|
142
|
+
- **Uninstall-then-install**, not `pm install -r` — debug APKs from different branches carry
|
|
143
|
+
different signing keys and otherwise fail with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`.
|
|
144
|
+
- **Check `mInputShown` before pressing Back**, so dismissing the keyboard never dismisses the
|
|
145
|
+
dialog behind it.
|
|
146
|
+
- **Write to Compose `TextField`s through the accessibility node**, not tap-then-type, which lands
|
|
147
|
+
text in the wrong field.
|
|
148
|
+
- **Settle after a click** before the next query, or you read pre-animation state.
|
|
149
|
+
- **An `appops` pass** for OEM permission overlays that keep blocking after `pm grant` reports
|
|
150
|
+
success.
|
|
151
|
+
|
|
152
|
+
</details>
|
|
153
|
+
|
|
154
|
+
## Configure
|
|
155
|
+
|
|
156
|
+
`.android-driver.yaml` at your project root is what makes a generic tool specific to your app.
|
|
157
|
+
`/android-driver:setup` writes a starter for you.
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
app:
|
|
161
|
+
package: com.example.myapp
|
|
162
|
+
activity: .MainActivity # optional; the launcher intent is resolved otherwise
|
|
163
|
+
|
|
164
|
+
build:
|
|
165
|
+
command: ./gradlew :app:assembleDebug
|
|
166
|
+
apk_glob: app/build/outputs/apk/debug/*.apk
|
|
167
|
+
|
|
168
|
+
driver:
|
|
169
|
+
backend: auto # auto | uiautomator2 | adb
|
|
170
|
+
|
|
171
|
+
selectors: # scanned, so a typo is a warning rather than a mystery
|
|
172
|
+
sources: ["app/src/main/**/*.kt"]
|
|
173
|
+
|
|
174
|
+
recipes: # each becomes an MCP tool with typed parameters
|
|
175
|
+
login:
|
|
176
|
+
params: {email: {required: true}, password: {required: true, secret: true}}
|
|
177
|
+
steps:
|
|
178
|
+
- launch:
|
|
179
|
+
- type: {desc: text_field_Email, text: "{{email}}"}
|
|
180
|
+
- tap: {desc: login_button}
|
|
181
|
+
- expect_visible: {desc: home_greeting, timeout_s: 20}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The file is optional: with no config every generic tool still works — you pass `pkg=` explicitly and
|
|
185
|
+
lose `build_app` and recipes. It is found by walking up from your project and then, failing that, up
|
|
186
|
+
to three levels *down*, so an app in `app/` or `android/` is discovered without configuration.
|
|
187
|
+
|
|
188
|
+
Full reference: **[docs/configuration.md](docs/configuration.md)** · recipe and step syntax:
|
|
189
|
+
**[docs/recipes.md](docs/recipes.md)** · worked examples for Compose and View projects:
|
|
190
|
+
[`examples/`](examples/).
|
|
191
|
+
|
|
192
|
+
## Tools
|
|
193
|
+
|
|
194
|
+
45, plus one per configured recipe.
|
|
195
|
+
|
|
196
|
+
| Group | Tools |
|
|
197
|
+
|---|---|
|
|
198
|
+
| Emulator | `list_avds` `start_emulator` `stop_emulator` `wait_for_boot` `snapshot_save` `snapshot_load` `snapshot_list` `snapshot_delete` |
|
|
199
|
+
| Device | `list_devices` `select_device` `device_info` |
|
|
200
|
+
| App | `build_app` `install_app` `uninstall_app` `app_info` `launch_app` `force_stop` `clear_app_data` |
|
|
201
|
+
| UI | `screen` `tap` `tap_xy` `long_press` `type_text` `swipe` `scroll_to` `press_key` `screenshot` `dump_ui_xml` |
|
|
202
|
+
| Assertions | `expect_visible` `expect_gone` `expect_log` `expect_no_crash` |
|
|
203
|
+
| Runs | `run_start` `run_end` `run_list` `record_start` `record_stop` |
|
|
204
|
+
| Recipes | `list_recipes` `run_recipe` `check_recipes` `list_selectors` `reload_config` |
|
|
205
|
+
| Logs | `logcat_clear` `logcat_read` |
|
|
206
|
+
| Shell | `shell` |
|
|
207
|
+
|
|
208
|
+
> [!WARNING]
|
|
209
|
+
> `shell` is unrestricted on purpose — this is a development tool, not a sandbox. It can wipe device
|
|
210
|
+
> data, kill processes and read files. Point it at emulators and test devices, not at anything you
|
|
211
|
+
> care about.
|
|
212
|
+
|
|
213
|
+
### The loop, in tool calls
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
start_emulator(avd="Pixel_7") # reuses one that is already running
|
|
217
|
+
install_app(build_first=True)
|
|
218
|
+
launch_app()
|
|
219
|
+
snapshot_save("clean") # ← the state every attempt returns to
|
|
220
|
+
|
|
221
|
+
run_start("issue 412: crash on empty search")
|
|
222
|
+
… login() / tap / type_text / expect_visible …
|
|
223
|
+
expect_no_crash()
|
|
224
|
+
run_end() # → runs/<id>/report.md
|
|
225
|
+
|
|
226
|
+
snapshot_load("clean") # next variation, from identical state
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
[`docs/agent-guide.md`](docs/agent-guide.md) is a `CLAUDE.md` fragment you can drop into a project so
|
|
230
|
+
an agent picks this up without being told.
|
|
231
|
+
|
|
232
|
+
## Try it without your own app
|
|
233
|
+
|
|
234
|
+
The repo ships **[FlakyDemo](test_app/)** — a Compose app with five screens and **27 deliberately
|
|
235
|
+
planted bugs**, each documented in [`test_app/BUGS.md`](test_app/BUGS.md) with what was actually
|
|
236
|
+
observed rather than what was intended. Crashes, races, state lost on rotation, a Save button that
|
|
237
|
+
reports success and silently does nothing, and several bugs that only show up one run in three.
|
|
238
|
+
|
|
239
|
+
Its flake generator is seeded, so `--el flake_seed 42` replays the same failures every time and
|
|
240
|
+
`--ez flake_enabled false` turns them all off for a clean baseline. It ships with 11 recipes.
|
|
241
|
+
|
|
242
|
+
It is the fastest way to see what the tool is for — and a fair test of whether it earns its keep,
|
|
243
|
+
since a good number of the planted bugs are the kind a tap-and-screenshot agent cannot catch at all.
|
|
244
|
+
The Settings screen alone reports "Saved" in the UI while the log says
|
|
245
|
+
`outcome=noop reason=terms_not_accepted` and nothing was written.
|
|
246
|
+
|
|
247
|
+
## Development
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
uv sync --extra dev
|
|
251
|
+
uv run pytest # 129 unit tests against a fake driver, no device needed
|
|
252
|
+
uv run ruff check src tests
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Setup, the live suite, and the packaging pitfalls worth knowing about are in
|
|
256
|
+
**[CONTRIBUTING.md](CONTRIBUTING.md)**.
|
|
257
|
+
|
|
258
|
+
## Status
|
|
259
|
+
|
|
260
|
+
Early, but working end to end. Emulator lifecycle, snapshots, both driver backends, the screen index,
|
|
261
|
+
assertions, run bundles, screen recording, recipes and selector scanning are implemented and covered
|
|
262
|
+
by tests; the live suite passes against a Pixel 7 AVD. Packaged as a Claude Code plugin with a skill
|
|
263
|
+
and three commands.
|
|
264
|
+
|
|
265
|
+
Not on PyPI — the plugin builds from source, so it does not need to be. Roadmap:
|
|
266
|
+
[docs/roadmap.md](docs/roadmap.md). Issues and pull requests welcome.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# android-driver
|
|
2
|
+
|
|
3
|
+
**Drive an Android emulator as a deterministic test harness — from Claude Code.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/earlzdev/android-driver/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://www.python.org/downloads/)
|
|
8
|
+
|
|
9
|
+
You ask an agent to reproduce a bug in your Android app. It dumps 80 KB of accessibility XML into its
|
|
10
|
+
own context, taps something that turns out to be the wrong element, and when the bug does not appear
|
|
11
|
+
it cannot repeat what it just did — because the app is now three screens deep in a state nobody
|
|
12
|
+
recorded.
|
|
13
|
+
|
|
14
|
+
android-driver is built for that loop instead: **build → install → drive → assert → reset → repeat.**
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
> reproduce the crash when the bio field goes over 100 characters
|
|
18
|
+
|
|
19
|
+
snapshot_load("clean") 1.9s
|
|
20
|
+
open_settings() ok
|
|
21
|
+
type_text(id=text_field_Bio, text=110 chars) ok
|
|
22
|
+
expect_log("StringIndexOutOfBoundsException") ok — matched
|
|
23
|
+
expect_no_crash() FAILED — 1 crash record
|
|
24
|
+
|
|
25
|
+
runs/20260901-080304-repro-set-06/report.md
|
|
26
|
+
→ java.lang.StringIndexOutOfBoundsException: begin 0, end 120, length 110
|
|
27
|
+
at ...screens.SettingsScreenKt.SettingsScreen$textFields(SettingsScreen.kt:149)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Three attempts from the same snapshot, three identical results, and a directory of evidence to point
|
|
31
|
+
at. That is the whole idea.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
It is a Claude Code plugin: one install brings the tools, a skill that teaches Claude the loop, and
|
|
38
|
+
three slash commands.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
claude plugin marketplace add earlzdev/android-driver
|
|
42
|
+
claude plugin install android-driver@android-driver
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
There is no venv to manage — the plugin builds the Python server from source on demand, and passes
|
|
46
|
+
your project directory to it so your config is found wherever Claude was launched from.
|
|
47
|
+
|
|
48
|
+
**Requirements:** `adb` and the Android SDK's `emulator` on `PATH`, Python ≥ 3.10. For the faster
|
|
49
|
+
uiautomator2 backend run `python -m uiautomator2 init` once per device; without it the server falls
|
|
50
|
+
back to a pure-adb backend that needs nothing installed on the device.
|
|
51
|
+
|
|
52
|
+
Prefer a plain MCP server, or not using Claude Code at all? See
|
|
53
|
+
[docs/installation.md](docs/installation.md).
|
|
54
|
+
|
|
55
|
+
## Quickstart
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
/android-driver:setup
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
It checks your toolchain, finds your `applicationId` and build command, detects whether you are on
|
|
62
|
+
Compose or Views, writes a starter `.android-driver.yaml`, boots an emulator and proves the loop
|
|
63
|
+
works. Then:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
/android-driver:smoke # build, install, walk the main flows, assert nothing broke
|
|
67
|
+
/android-driver:repro <what is broken> # reproduce it from a snapshot and leave evidence
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or just talk to it — the `android-testing` skill loads automatically when a task involves driving the
|
|
71
|
+
app, so "check that login still works on a fresh install" does the right thing without ceremony.
|
|
72
|
+
|
|
73
|
+
## Why it works this way
|
|
74
|
+
|
|
75
|
+
Four decisions do most of the work.
|
|
76
|
+
|
|
77
|
+
**Snapshots, so a repro is actually reproducible.** `snapshot_save` freezes the emulator's exact
|
|
78
|
+
state; `snapshot_load` restores it and waits until the device is genuinely drivable again — 1.8–2.2s
|
|
79
|
+
measured on a Pixel 7 AVD. Reinstalling and re-navigating costs 30–90s *and drifts a little each
|
|
80
|
+
time*. An agent testing thirty variations of a hypothesis needs the cheap, identical option: the
|
|
81
|
+
difference between two attempts only means something if everything else was the same.
|
|
82
|
+
|
|
83
|
+
**A screen index instead of a wall of XML.** A raw `uiautomator dump` is 50–200 KB per screen — tens
|
|
84
|
+
of thousands of tokens for a model that just wants to know what it can tap. `screen` returns this:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
device=emulator-5554 app=com.example.app/.MainActivity screen=1080x2400 driver=uiautomator2
|
|
88
|
+
#5 [Scroll] id=settings_container (scrollable) @(540,1236)
|
|
89
|
+
#7 [Text] "Settings" id=homepage_title @(235,472)
|
|
90
|
+
#18 [EditText] "" id=text_field_Bio @(540,1018)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Then `tap(ref="#7")`. Two orders of magnitude smaller, and it reads like a menu. The raw tree is
|
|
94
|
+
still there behind `dump_ui_xml` for when you genuinely need it.
|
|
95
|
+
|
|
96
|
+
**Assertions that collect their own evidence.** `expect_visible` polls, so it is safe immediately
|
|
97
|
+
after a tap and will not flake on an animation; when it fails it hands back the screen index that
|
|
98
|
+
*was* there, plus a screenshot and hierarchy dump on disk. `expect_no_crash` reads the `crash` buffer
|
|
99
|
+
as well as `main`, because a native abort never reaches `main` at all. Wrap a sequence in
|
|
100
|
+
`run_start` / `run_end` and you get `runs/<id>/` holding a timeline, a report, the logcat slice for
|
|
101
|
+
exactly that window, and every failure artifact — so an agent cites a directory instead of describing
|
|
102
|
+
what it saw.
|
|
103
|
+
|
|
104
|
+
**Your flows as first-class tools.** The six steps every test starts with — sign in, create an order,
|
|
105
|
+
join a call — go into `.android-driver.yaml` once and become real MCP tools with typed parameters. An
|
|
106
|
+
agent sees `login(email, password)` in its tool list rather than rediscovering the flow from a screen
|
|
107
|
+
dump every session. Recipes run the same code path as the hand-driven tools, so the two cannot drift.
|
|
108
|
+
|
|
109
|
+
<details>
|
|
110
|
+
<summary>Plus the device knowledge that costs an afternoon each to learn</summary>
|
|
111
|
+
|
|
112
|
+
- **Uninstall-then-install**, not `pm install -r` — debug APKs from different branches carry
|
|
113
|
+
different signing keys and otherwise fail with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`.
|
|
114
|
+
- **Check `mInputShown` before pressing Back**, so dismissing the keyboard never dismisses the
|
|
115
|
+
dialog behind it.
|
|
116
|
+
- **Write to Compose `TextField`s through the accessibility node**, not tap-then-type, which lands
|
|
117
|
+
text in the wrong field.
|
|
118
|
+
- **Settle after a click** before the next query, or you read pre-animation state.
|
|
119
|
+
- **An `appops` pass** for OEM permission overlays that keep blocking after `pm grant` reports
|
|
120
|
+
success.
|
|
121
|
+
|
|
122
|
+
</details>
|
|
123
|
+
|
|
124
|
+
## Configure
|
|
125
|
+
|
|
126
|
+
`.android-driver.yaml` at your project root is what makes a generic tool specific to your app.
|
|
127
|
+
`/android-driver:setup` writes a starter for you.
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
app:
|
|
131
|
+
package: com.example.myapp
|
|
132
|
+
activity: .MainActivity # optional; the launcher intent is resolved otherwise
|
|
133
|
+
|
|
134
|
+
build:
|
|
135
|
+
command: ./gradlew :app:assembleDebug
|
|
136
|
+
apk_glob: app/build/outputs/apk/debug/*.apk
|
|
137
|
+
|
|
138
|
+
driver:
|
|
139
|
+
backend: auto # auto | uiautomator2 | adb
|
|
140
|
+
|
|
141
|
+
selectors: # scanned, so a typo is a warning rather than a mystery
|
|
142
|
+
sources: ["app/src/main/**/*.kt"]
|
|
143
|
+
|
|
144
|
+
recipes: # each becomes an MCP tool with typed parameters
|
|
145
|
+
login:
|
|
146
|
+
params: {email: {required: true}, password: {required: true, secret: true}}
|
|
147
|
+
steps:
|
|
148
|
+
- launch:
|
|
149
|
+
- type: {desc: text_field_Email, text: "{{email}}"}
|
|
150
|
+
- tap: {desc: login_button}
|
|
151
|
+
- expect_visible: {desc: home_greeting, timeout_s: 20}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The file is optional: with no config every generic tool still works — you pass `pkg=` explicitly and
|
|
155
|
+
lose `build_app` and recipes. It is found by walking up from your project and then, failing that, up
|
|
156
|
+
to three levels *down*, so an app in `app/` or `android/` is discovered without configuration.
|
|
157
|
+
|
|
158
|
+
Full reference: **[docs/configuration.md](docs/configuration.md)** · recipe and step syntax:
|
|
159
|
+
**[docs/recipes.md](docs/recipes.md)** · worked examples for Compose and View projects:
|
|
160
|
+
[`examples/`](examples/).
|
|
161
|
+
|
|
162
|
+
## Tools
|
|
163
|
+
|
|
164
|
+
45, plus one per configured recipe.
|
|
165
|
+
|
|
166
|
+
| Group | Tools |
|
|
167
|
+
|---|---|
|
|
168
|
+
| Emulator | `list_avds` `start_emulator` `stop_emulator` `wait_for_boot` `snapshot_save` `snapshot_load` `snapshot_list` `snapshot_delete` |
|
|
169
|
+
| Device | `list_devices` `select_device` `device_info` |
|
|
170
|
+
| App | `build_app` `install_app` `uninstall_app` `app_info` `launch_app` `force_stop` `clear_app_data` |
|
|
171
|
+
| UI | `screen` `tap` `tap_xy` `long_press` `type_text` `swipe` `scroll_to` `press_key` `screenshot` `dump_ui_xml` |
|
|
172
|
+
| Assertions | `expect_visible` `expect_gone` `expect_log` `expect_no_crash` |
|
|
173
|
+
| Runs | `run_start` `run_end` `run_list` `record_start` `record_stop` |
|
|
174
|
+
| Recipes | `list_recipes` `run_recipe` `check_recipes` `list_selectors` `reload_config` |
|
|
175
|
+
| Logs | `logcat_clear` `logcat_read` |
|
|
176
|
+
| Shell | `shell` |
|
|
177
|
+
|
|
178
|
+
> [!WARNING]
|
|
179
|
+
> `shell` is unrestricted on purpose — this is a development tool, not a sandbox. It can wipe device
|
|
180
|
+
> data, kill processes and read files. Point it at emulators and test devices, not at anything you
|
|
181
|
+
> care about.
|
|
182
|
+
|
|
183
|
+
### The loop, in tool calls
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
start_emulator(avd="Pixel_7") # reuses one that is already running
|
|
187
|
+
install_app(build_first=True)
|
|
188
|
+
launch_app()
|
|
189
|
+
snapshot_save("clean") # ← the state every attempt returns to
|
|
190
|
+
|
|
191
|
+
run_start("issue 412: crash on empty search")
|
|
192
|
+
… login() / tap / type_text / expect_visible …
|
|
193
|
+
expect_no_crash()
|
|
194
|
+
run_end() # → runs/<id>/report.md
|
|
195
|
+
|
|
196
|
+
snapshot_load("clean") # next variation, from identical state
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
[`docs/agent-guide.md`](docs/agent-guide.md) is a `CLAUDE.md` fragment you can drop into a project so
|
|
200
|
+
an agent picks this up without being told.
|
|
201
|
+
|
|
202
|
+
## Try it without your own app
|
|
203
|
+
|
|
204
|
+
The repo ships **[FlakyDemo](test_app/)** — a Compose app with five screens and **27 deliberately
|
|
205
|
+
planted bugs**, each documented in [`test_app/BUGS.md`](test_app/BUGS.md) with what was actually
|
|
206
|
+
observed rather than what was intended. Crashes, races, state lost on rotation, a Save button that
|
|
207
|
+
reports success and silently does nothing, and several bugs that only show up one run in three.
|
|
208
|
+
|
|
209
|
+
Its flake generator is seeded, so `--el flake_seed 42` replays the same failures every time and
|
|
210
|
+
`--ez flake_enabled false` turns them all off for a clean baseline. It ships with 11 recipes.
|
|
211
|
+
|
|
212
|
+
It is the fastest way to see what the tool is for — and a fair test of whether it earns its keep,
|
|
213
|
+
since a good number of the planted bugs are the kind a tap-and-screenshot agent cannot catch at all.
|
|
214
|
+
The Settings screen alone reports "Saved" in the UI while the log says
|
|
215
|
+
`outcome=noop reason=terms_not_accepted` and nothing was written.
|
|
216
|
+
|
|
217
|
+
## Development
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
uv sync --extra dev
|
|
221
|
+
uv run pytest # 129 unit tests against a fake driver, no device needed
|
|
222
|
+
uv run ruff check src tests
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Setup, the live suite, and the packaging pitfalls worth knowing about are in
|
|
226
|
+
**[CONTRIBUTING.md](CONTRIBUTING.md)**.
|
|
227
|
+
|
|
228
|
+
## Status
|
|
229
|
+
|
|
230
|
+
Early, but working end to end. Emulator lifecycle, snapshots, both driver backends, the screen index,
|
|
231
|
+
assertions, run bundles, screen recording, recipes and selector scanning are implemented and covered
|
|
232
|
+
by tests; the live suite passes against a Pixel 7 AVD. Packaged as a Claude Code plugin with a skill
|
|
233
|
+
and three commands.
|
|
234
|
+
|
|
235
|
+
Not on PyPI — the plugin builds from source, so it does not need to be. Roadmap:
|
|
236
|
+
[docs/roadmap.md](docs/roadmap.md). Issues and pull requests welcome.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
MIT
|