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/run.py ADDED
@@ -0,0 +1,292 @@
1
+ """Run bundles: every artifact from one test attempt, in one directory.
2
+
3
+ An agent that finds a bug and then cannot show you *why* it believes that has
4
+ done half the work. A run collects the evidence as it goes — a timeline of every
5
+ action with its timing, a screenshot and hierarchy dump at each failure, and the
6
+ logcat slice for exactly that window — and writes a report a human can read
7
+ without replaying anything.
8
+
9
+ Artifacts are captured even with no run open: they land under
10
+ `runs/failures/<timestamp>/` instead. A failure you have to reproduce in order
11
+ to collect evidence for is a failure you have already half lost.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from . import adb
23
+ from .config import Config
24
+ from .log import log
25
+ from .session import Session
26
+
27
+ _SLUG_OK = "abcdefghijklmnopqrstuvwxyz0123456789-_"
28
+
29
+
30
+ def _slug(value: str, limit: int = 40) -> str:
31
+ cleaned = "".join(c if c in _SLUG_OK else "-" for c in value.strip().lower())
32
+ while "--" in cleaned:
33
+ cleaned = cleaned.replace("--", "-")
34
+ return cleaned.strip("-")[:limit] or "run"
35
+
36
+
37
+ @dataclass
38
+ class Event:
39
+ t: float
40
+ tool: str
41
+ status: str
42
+ duration_s: float
43
+ detail: dict[str, Any] = field(default_factory=dict)
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ return {
47
+ "t": round(self.t, 3),
48
+ "tool": self.tool,
49
+ "status": self.status,
50
+ "duration_s": self.duration_s,
51
+ **({"detail": self.detail} if self.detail else {}),
52
+ }
53
+
54
+
55
+ class Run:
56
+ """One test attempt and the directory holding its evidence."""
57
+
58
+ def __init__(self, root: Path, name: str, note: str = "") -> None:
59
+ stamp = time.strftime("%Y%m%d-%H%M%S")
60
+ self.name = name
61
+ self.note = note
62
+ self.id = f"{stamp}-{_slug(name)}"
63
+ self.dir = root / self.id
64
+ self.dir.mkdir(parents=True, exist_ok=True)
65
+ self.started_at = time.time()
66
+ self.started_mono = time.monotonic()
67
+ self.events: list[Event] = []
68
+ self.device: dict[str, str] = {}
69
+ self.recording: dict[str, Any] | None = None
70
+ self.finished = False
71
+
72
+ # ── recording events ─────────────────────────────────────────────────────
73
+
74
+ def event(self, tool: str, status: str, duration_s: float, detail: dict[str, Any] | None = None) -> Event:
75
+ ev = Event(
76
+ t=time.monotonic() - self.started_mono,
77
+ tool=tool,
78
+ status=status,
79
+ duration_s=round(duration_s, 3),
80
+ detail=detail or {},
81
+ )
82
+ self.events.append(ev)
83
+ return ev
84
+
85
+ def artifact(self, name: str) -> Path:
86
+ path = self.dir / name
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ return path
89
+
90
+ @property
91
+ def failures(self) -> list[Event]:
92
+ return [e for e in self.events if e.status != "ok"]
93
+
94
+ # ── output ───────────────────────────────────────────────────────────────
95
+
96
+ def write_timeline(self) -> Path:
97
+ payload = {
98
+ "id": self.id,
99
+ "name": self.name,
100
+ "note": self.note,
101
+ "started_at": self.started_at,
102
+ "duration_s": round(time.monotonic() - self.started_mono, 2),
103
+ "device": self.device,
104
+ "recording": self.recording,
105
+ "events": [e.to_dict() for e in self.events],
106
+ }
107
+ path = self.dir / "timeline.json"
108
+ path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
109
+ return path
110
+
111
+ def write_report(self) -> Path:
112
+ total = time.monotonic() - self.started_mono
113
+ failures = self.failures
114
+ lines = [
115
+ f"# Run {self.id}",
116
+ "",
117
+ f"- **name**: {self.name}",
118
+ f"- **started**: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(self.started_at))}",
119
+ f"- **duration**: {total:.1f}s",
120
+ f"- **steps**: {len(self.events)} ({len(failures)} failed)",
121
+ ]
122
+ if self.note:
123
+ lines.append(f"- **note**: {self.note}")
124
+ for key in ("serial", "model", "android_version", "sdk", "screen"):
125
+ if self.device.get(key):
126
+ lines.append(f"- **{key}**: {self.device[key]}")
127
+ if self.recording:
128
+ lines.append(f"- **recording**: `{self.recording.get('path', '?')}`")
129
+
130
+ lines += ["", "## Timeline", "", "| t | step | status | took |", "|---:|---|---|---:|"]
131
+ for e in self.events:
132
+ lines.append(f"| {e.t:6.1f}s | `{e.tool}` | {e.status} | {e.duration_s:.2f}s |")
133
+
134
+ if failures:
135
+ lines += ["", "## Failures", ""]
136
+ for e in failures:
137
+ lines.append(f"### `{e.tool}` at {e.t:.1f}s")
138
+ lines.append("")
139
+ error = e.detail.get("error")
140
+ if error:
141
+ lines += ["```", str(error), "```", ""]
142
+ for key in ("screenshot", "hierarchy"):
143
+ if e.detail.get(key):
144
+ lines.append(f"- {key}: `{e.detail[key]}`")
145
+ lines.append("")
146
+
147
+ lines += ["", "## Artifacts", ""]
148
+ for path in sorted(self.dir.rglob("*")):
149
+ if path.is_file() and path.name != "report.md":
150
+ lines.append(f"- `{path.relative_to(self.dir)}`")
151
+
152
+ path = self.dir / "report.md"
153
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
154
+ return path
155
+
156
+ def write_logcat(self, serial: str, pkg: str | None) -> Path | None:
157
+ """The log for this run's window. Assumes the buffer was cleared at the start."""
158
+ try:
159
+ lines = adb.logcat_dump(serial, lines=20000, buffers="main,crash")
160
+ except Exception as e:
161
+ log("run", f"could not capture logcat: {e}")
162
+ return None
163
+ path = self.dir / "logcat.txt"
164
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
165
+ if pkg:
166
+ app_lines = [line for line in lines if pkg in line]
167
+ if app_lines:
168
+ (self.dir / "logcat-app.txt").write_text("\n".join(app_lines) + "\n", encoding="utf-8")
169
+ return path
170
+
171
+
172
+ class Runs:
173
+ """Owns the current run and the fallback artifact directory."""
174
+
175
+ def __init__(self, cfg: Config) -> None:
176
+ self.cfg = cfg
177
+ self.current: Run | None = None
178
+
179
+ @property
180
+ def root(self) -> Path:
181
+ return self.cfg.runs_dir
182
+
183
+ def start(self, session: Session, name: str, note: str = "", clear_log: bool = True) -> dict[str, Any]:
184
+ if self.current is not None and not self.current.finished:
185
+ previous = self.current.id
186
+ self.end(session)
187
+ log("run", f"auto-closed the previous run {previous}")
188
+ run = Run(self.root, name, note)
189
+ try:
190
+ run.device = adb.device_info(session.serial)
191
+ except Exception as e:
192
+ log("run", f"could not read device info: {e}")
193
+ if clear_log:
194
+ try:
195
+ adb.logcat_clear(session.serial)
196
+ except Exception as e:
197
+ log("run", f"could not clear logcat: {e}")
198
+ self.current = run
199
+ log("run", f"started {run.id} → {run.dir}")
200
+ return {"run_id": run.id, "dir": str(run.dir)}
201
+
202
+ def end(self, session: Session) -> dict[str, Any]:
203
+ run = self.current
204
+ if run is None or run.finished:
205
+ raise RuntimeError("no run is open — call `run_start` first")
206
+ pkg = self.cfg.app.package
207
+ serial = run.device.get("serial") or (session.current_serial or "")
208
+ if serial:
209
+ run.write_logcat(serial, pkg)
210
+ timeline = run.write_timeline()
211
+ report = run.write_report()
212
+ run.finished = True
213
+ self.current = None
214
+ failures = [e.tool for e in run.failures]
215
+ log("run", f"finished {run.id} ({len(run.events)} steps, {len(failures)} failed)")
216
+ return {
217
+ "run_id": run.id,
218
+ "dir": str(run.dir),
219
+ "steps": len(run.events),
220
+ "failed": failures,
221
+ "passed": not failures,
222
+ "report": str(report),
223
+ "timeline": str(timeline),
224
+ }
225
+
226
+ # ── artifacts ────────────────────────────────────────────────────────────
227
+
228
+ def artifact_dir(self, kind: str = "failures") -> Path:
229
+ if self.current is not None and not self.current.finished:
230
+ return self.current.dir
231
+ path = self.root / kind / time.strftime("%Y%m%d-%H%M%S")
232
+ path.mkdir(parents=True, exist_ok=True)
233
+ return path
234
+
235
+ def capture(self, session: Session, tag: str) -> dict[str, str]:
236
+ """Screenshot + hierarchy for post-hoc triage. Never raises."""
237
+ out = self.artifact_dir()
238
+ stamp = f"{tag}-{int(time.time() * 1000) % 100000}"
239
+ captured: dict[str, str] = {}
240
+ png = out / f"{stamp}.png"
241
+ try:
242
+ session.driver.screenshot(png)
243
+ captured["screenshot"] = str(png)
244
+ except Exception as e:
245
+ log("run", f"screenshot capture failed for {tag}: {e}")
246
+ xml = out / f"{stamp}.xml"
247
+ try:
248
+ xml.write_text(session.driver.dump_hierarchy(), encoding="utf-8")
249
+ captured["hierarchy"] = str(xml)
250
+ except Exception as e:
251
+ log("run", f"hierarchy capture failed for {tag}: {e}")
252
+ if not captured:
253
+ # An unreachable device fails both captures; don't leave the empty
254
+ # directory behind to look like evidence that exists.
255
+ for path in (png, xml):
256
+ path.unlink(missing_ok=True)
257
+ if out != getattr(self.current, "dir", None) and not any(out.iterdir()):
258
+ out.rmdir()
259
+ return captured
260
+
261
+ def record_event(
262
+ self, tool: str, status: str, duration_s: float, detail: dict[str, Any] | None = None
263
+ ) -> None:
264
+ if self.current is not None and not self.current.finished:
265
+ self.current.event(tool, status, duration_s, detail)
266
+
267
+ def list_runs(self, limit: int = 20) -> list[dict[str, Any]]:
268
+ if not self.root.is_dir():
269
+ return []
270
+ out: list[dict[str, Any]] = []
271
+ for path in sorted(self.root.iterdir(), reverse=True):
272
+ timeline = path / "timeline.json"
273
+ if not timeline.is_file():
274
+ continue
275
+ try:
276
+ data = json.loads(timeline.read_text(encoding="utf-8"))
277
+ except (OSError, json.JSONDecodeError):
278
+ continue
279
+ failed = [e["tool"] for e in data.get("events", []) if e.get("status") != "ok"]
280
+ out.append(
281
+ {
282
+ "run_id": data.get("id", path.name),
283
+ "name": data.get("name", ""),
284
+ "duration_s": data.get("duration_s"),
285
+ "steps": len(data.get("events", [])),
286
+ "failed": failed,
287
+ "dir": str(path),
288
+ }
289
+ )
290
+ if len(out) >= limit:
291
+ break
292
+ return out
android_driver/scan.py ADDED
@@ -0,0 +1,155 @@
1
+ """Selector scanning — what names actually exist in the app under test.
2
+
3
+ A recipe written against `desc: login_buton` fails at step four with "element not
4
+ found", and the agent then spends three tool calls proving the typo. Scanning the
5
+ project's own sources for the literals it declares turns that into a load-time
6
+ warning, and gives an agent a list of real names to write recipes against in the
7
+ first place.
8
+
9
+ The default patterns cover the two things Android apps actually label elements
10
+ with — Compose `testTag`/`contentDescription` and View-system `android:id` — plus
11
+ string resources, since visible copy is what `text:` selectors match. Projects
12
+ with their own convention add regexes under `selectors.patterns`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from .config import Config
22
+ from .log import log
23
+
24
+ # Default globs, tried when the config does not name any.
25
+ DEFAULT_SOURCES = (
26
+ "**/src/**/*.kt",
27
+ "**/src/**/*.java",
28
+ "**/src/main/res/layout*/*.xml",
29
+ "**/src/main/res/values/strings.xml",
30
+ )
31
+
32
+ # Directories never worth walking — build outputs dwarf the sources.
33
+ SKIP_DIRS = {"build", ".git", ".gradle", ".idea", "node_modules", "venv", ".venv", "__pycache__"}
34
+
35
+ # kind → regex with one capturing group holding the literal.
36
+ PATTERNS: dict[str, re.Pattern[str]] = {
37
+ # testTag("x"), testSemanticsTag("x"), fooTestTag = "x"
38
+ "tag": re.compile(r'[A-Za-z_]*[Tt]est(?:Semantics)?[Tt]ag\b\s*[=(]\s*"([^"\n]+)"'),
39
+ # contentDescription = "x" / contentDescription("x")
40
+ "desc": re.compile(r'contentDescription\s*[=(]\s*"([^"\n]+)"'),
41
+ # android:id="@+id/x" and R.id.x
42
+ "id": re.compile(r'android:id\s*=\s*"@\+?id/([\w.]+)"'),
43
+ "id_ref": re.compile(r"\bR\.id\.(\w+)"),
44
+ # <string name="x">visible copy</string>
45
+ "text": re.compile(r'<string\s+name="[^"]+"\s*>([^<]{1,80})</string>'),
46
+ }
47
+
48
+ # Literals that resolve at runtime (`testTag = "demo_${item.name}_button"`) can
49
+ # never match a live element as written, so they are reported separately rather
50
+ # than presented as names a recipe can use.
51
+ TEMPLATE_RE = re.compile(r"\$\{|\$[A-Za-z_]|%[sd]|\{\d*\}")
52
+
53
+
54
+ @dataclass
55
+ class Selectors:
56
+ by_kind: dict[str, set[str]]
57
+ templates: set[str]
58
+ files_scanned: int
59
+
60
+ @property
61
+ def all(self) -> set[str]:
62
+ return {value for values in self.by_kind.values() for value in values}
63
+
64
+ def to_dict(self, limit: int | None = None) -> dict[str, list[str]]:
65
+ out = {kind: sorted(values) for kind, values in sorted(self.by_kind.items()) if values}
66
+ if limit:
67
+ out = {kind: values[:limit] for kind, values in out.items()}
68
+ return out
69
+
70
+
71
+ def _iter_files(root: Path, globs: list[str]) -> list[Path]:
72
+ seen: dict[Path, None] = {}
73
+ for pattern in globs:
74
+ for path in root.glob(pattern):
75
+ if not path.is_file():
76
+ continue
77
+ if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
78
+ continue
79
+ seen[path] = None
80
+ return list(seen)
81
+
82
+
83
+ def scan(cfg: Config) -> Selectors:
84
+ """Collect every selector literal the project declares."""
85
+ spec = cfg.selectors or {}
86
+ globs = list(spec.get("sources") or DEFAULT_SOURCES)
87
+ extra = spec.get("patterns") or []
88
+
89
+ patterns = dict(PATTERNS)
90
+ for i, raw in enumerate(extra):
91
+ try:
92
+ patterns[f"custom{i + 1}"] = re.compile(raw)
93
+ except re.error as e:
94
+ log("scan", f"ignoring selectors.patterns[{i}] {raw!r}: {e}")
95
+
96
+ by_kind: dict[str, set[str]] = {kind: set() for kind in patterns}
97
+ templates: set[str] = set()
98
+ files = _iter_files(cfg.project_root, globs)
99
+
100
+ for path in files:
101
+ try:
102
+ text = path.read_text(encoding="utf-8", errors="replace")
103
+ except OSError as e:
104
+ log("scan", f"could not read {path}: {e}")
105
+ continue
106
+ for kind, rx in patterns.items():
107
+ for match in rx.finditer(text):
108
+ literal = (match.group(1) if rx.groups else match.group(0)).strip()
109
+ if not literal:
110
+ continue
111
+ if TEMPLATE_RE.search(literal):
112
+ templates.add(literal)
113
+ else:
114
+ by_kind[kind].add(literal)
115
+
116
+ # id and id_ref are the same namespace seen from XML and from Kotlin.
117
+ by_kind["id"] |= by_kind.pop("id_ref", set())
118
+ log("scan", f"scanned {len(files)} file(s); found {sum(len(v) for v in by_kind.values())} selectors")
119
+ return Selectors(by_kind=by_kind, templates=templates, files_scanned=len(files))
120
+
121
+
122
+ def check_recipes(recipes: dict, known: Selectors) -> list[str]:
123
+ """Warn about recipe selectors the project's sources do not declare.
124
+
125
+ Only `desc` and `id` are checked. `text` selectors match visible copy that may
126
+ come from a translation, a server response or a formatted string, so absence
127
+ from the scanned set means nothing — flagging it would produce noise an
128
+ operator learns to ignore, which is worse than not checking.
129
+ """
130
+ if not known.all:
131
+ return []
132
+ warnings: list[str] = []
133
+ tags = known.by_kind.get("tag", set()) | known.by_kind.get("desc", set())
134
+ ids = known.by_kind.get("id", set())
135
+ for name, recipe in recipes.items():
136
+ for index, step in enumerate(recipe.steps, start=1):
137
+ for key, pool, label in (("desc", tags, "testTag/contentDescription"), ("id", ids, "id")):
138
+ value = step.args.get(key) if isinstance(step.args, dict) else None
139
+ if not isinstance(value, str) or "{{" in value or not pool:
140
+ continue
141
+ if value not in pool:
142
+ near = _closest(value, pool)
143
+ hint = f" Did you mean {near!r}?" if near else ""
144
+ warnings.append(
145
+ f"{name} step {index} (`{step.verb}`) uses {key}={value!r}, "
146
+ f"which is not among the project's {label} literals.{hint}"
147
+ )
148
+ return warnings
149
+
150
+
151
+ def _closest(value: str, pool: set[str]) -> str | None:
152
+ import difflib
153
+
154
+ matches = difflib.get_close_matches(value, pool, n=1, cutoff=0.75)
155
+ return matches[0] if matches else None