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,547 @@
|
|
|
1
|
+
"""YAML flows — the project's own test verbs, declared in config.
|
|
2
|
+
|
|
3
|
+
A recipe is a named sequence of steps a project runs over and over: sign in,
|
|
4
|
+
create an order, join a call. Written once in `.android-driver.yaml`, each one is
|
|
5
|
+
registered as a real MCP tool with typed parameters, so an agent sees
|
|
6
|
+
`login(email, password)` in its tool list rather than having to rediscover a
|
|
7
|
+
six-step flow from a screen dump every session.
|
|
8
|
+
|
|
9
|
+
recipes:
|
|
10
|
+
login:
|
|
11
|
+
description: Sign in and land on the home screen
|
|
12
|
+
params:
|
|
13
|
+
email: {required: true}
|
|
14
|
+
password: {required: true, secret: true}
|
|
15
|
+
steps:
|
|
16
|
+
- launch:
|
|
17
|
+
- type: {id: email_field, text: "{{email}}"}
|
|
18
|
+
- type: {id: password_field, text: "{{password}}"}
|
|
19
|
+
- tap: "Sign in"
|
|
20
|
+
- expect_visible: {text: Welcome, timeout_s: 20}
|
|
21
|
+
|
|
22
|
+
Steps run through the same `actions` functions the hand-driven tools use, so a
|
|
23
|
+
recipe cannot drift away from what an agent does interactively.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import inspect
|
|
29
|
+
import re
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from . import actions, adb, emulator, expect
|
|
36
|
+
from .config import Config
|
|
37
|
+
from .log import log
|
|
38
|
+
from .run import Runs
|
|
39
|
+
from .session import Session
|
|
40
|
+
|
|
41
|
+
MAX_RECIPE_DEPTH = 5
|
|
42
|
+
NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
43
|
+
PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")
|
|
44
|
+
WHOLE_PLACEHOLDER_RE = re.compile(r"^\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}$")
|
|
45
|
+
|
|
46
|
+
PARAM_TYPES: dict[str, type] = {"str": str, "int": int, "float": float, "bool": bool}
|
|
47
|
+
|
|
48
|
+
# Keys on a step mapping that configure the step rather than the verb.
|
|
49
|
+
STEP_META = {"retry", "optional", "settle_s", "label"}
|
|
50
|
+
|
|
51
|
+
# Verbs whose single obvious argument can be written as a bare scalar.
|
|
52
|
+
SCALAR_ARG = {
|
|
53
|
+
"sleep": "seconds",
|
|
54
|
+
"press": "key",
|
|
55
|
+
"shell": "cmd",
|
|
56
|
+
"screenshot": "name",
|
|
57
|
+
"launch": "pkg",
|
|
58
|
+
"force_stop": "pkg",
|
|
59
|
+
"clear_data": "pkg",
|
|
60
|
+
"install": "apk_path",
|
|
61
|
+
"snapshot_save": "name",
|
|
62
|
+
"snapshot_load": "name",
|
|
63
|
+
"expect_log": "pattern",
|
|
64
|
+
"run": "recipe",
|
|
65
|
+
"tap": "text",
|
|
66
|
+
"long_press": "text",
|
|
67
|
+
"scroll_to": "text",
|
|
68
|
+
"expect_visible": "text",
|
|
69
|
+
"expect_gone": "text",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RecipeError(RuntimeError):
|
|
74
|
+
"""A recipe is malformed. Raised at load time, never mid-run."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class StepFailed(RuntimeError):
|
|
78
|
+
"""A step failed at runtime. Carries the structured detail for the report."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, message: str, detail: dict[str, Any] | None = None) -> None:
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
self.detail = detail or {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── model ─────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Param:
|
|
90
|
+
name: str
|
|
91
|
+
type: str = "str"
|
|
92
|
+
required: bool = False
|
|
93
|
+
default: Any = None
|
|
94
|
+
description: str = ""
|
|
95
|
+
secret: bool = False
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def py_type(self) -> type:
|
|
99
|
+
return PARAM_TYPES[self.type]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class Step:
|
|
104
|
+
verb: str
|
|
105
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
106
|
+
retry: int = 0
|
|
107
|
+
optional: bool = False
|
|
108
|
+
settle_s: float = 0.0
|
|
109
|
+
label: str = ""
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def name(self) -> str:
|
|
113
|
+
return self.label or self.verb
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Recipe:
|
|
118
|
+
name: str
|
|
119
|
+
description: str
|
|
120
|
+
params: list[Param]
|
|
121
|
+
steps: list[Step]
|
|
122
|
+
on_failure: str = "stop"
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def param_map(self) -> dict[str, Param]:
|
|
126
|
+
return {p.name: p for p in self.params}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_params(name: str, raw: Any) -> list[Param]:
|
|
130
|
+
if raw is None:
|
|
131
|
+
return []
|
|
132
|
+
if isinstance(raw, list): # shorthand: a list of required string params
|
|
133
|
+
return [Param(name=str(p), required=True) for p in raw]
|
|
134
|
+
if not isinstance(raw, dict):
|
|
135
|
+
raise RecipeError(f"recipe {name!r}: `params` must be a mapping or a list, got {type(raw).__name__}")
|
|
136
|
+
|
|
137
|
+
params: list[Param] = []
|
|
138
|
+
for key, spec in raw.items():
|
|
139
|
+
if not NAME_RE.match(str(key)):
|
|
140
|
+
raise RecipeError(f"recipe {name!r}: {key!r} is not a valid parameter name (a-z, 0-9, _)")
|
|
141
|
+
spec = spec or {}
|
|
142
|
+
if not isinstance(spec, dict):
|
|
143
|
+
spec = {"default": spec}
|
|
144
|
+
unknown = set(spec) - {"type", "required", "default", "description", "secret"}
|
|
145
|
+
if unknown:
|
|
146
|
+
raise RecipeError(f"recipe {name!r}: parameter {key!r} has unknown key(s) {sorted(unknown)}")
|
|
147
|
+
ptype = str(spec.get("type", "str"))
|
|
148
|
+
if ptype not in PARAM_TYPES:
|
|
149
|
+
raise RecipeError(
|
|
150
|
+
f"recipe {name!r}: parameter {key!r} has type {ptype!r}; use one of {sorted(PARAM_TYPES)}"
|
|
151
|
+
)
|
|
152
|
+
params.append(
|
|
153
|
+
Param(
|
|
154
|
+
name=str(key),
|
|
155
|
+
type=ptype,
|
|
156
|
+
required=bool(spec.get("required", "default" not in spec)),
|
|
157
|
+
default=spec.get("default"),
|
|
158
|
+
description=str(spec.get("description", "")),
|
|
159
|
+
secret=bool(spec.get("secret", False)),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return params
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_step(recipe: str, index: int, raw: Any) -> Step:
|
|
166
|
+
where = f"recipe {recipe!r} step {index + 1}"
|
|
167
|
+
if isinstance(raw, str): # bare verb, no arguments: `- launch`
|
|
168
|
+
raw = {raw: None}
|
|
169
|
+
if not isinstance(raw, dict):
|
|
170
|
+
raise RecipeError(f"{where}: each step must be a mapping like `- tap: {{text: OK}}`")
|
|
171
|
+
|
|
172
|
+
meta = {k: v for k, v in raw.items() if k in STEP_META}
|
|
173
|
+
verbs = [k for k in raw if k not in STEP_META]
|
|
174
|
+
if len(verbs) != 1:
|
|
175
|
+
raise RecipeError(
|
|
176
|
+
f"{where}: expected exactly one verb, found {verbs or 'none'}. "
|
|
177
|
+
"Write each action as its own list item."
|
|
178
|
+
)
|
|
179
|
+
verb = verbs[0]
|
|
180
|
+
if verb not in VERBS:
|
|
181
|
+
raise RecipeError(f"{where}: unknown step {verb!r}. Known steps: {sorted(VERBS)}")
|
|
182
|
+
|
|
183
|
+
args = raw[verb]
|
|
184
|
+
if args is None:
|
|
185
|
+
args = {}
|
|
186
|
+
elif not isinstance(args, dict):
|
|
187
|
+
key = SCALAR_ARG.get(verb)
|
|
188
|
+
if key is None:
|
|
189
|
+
raise RecipeError(f"{where}: `{verb}` needs a mapping of arguments, got {args!r}")
|
|
190
|
+
args = {key: args}
|
|
191
|
+
else:
|
|
192
|
+
# `retry` reads naturally beside `timeout_s`, so accept step options written
|
|
193
|
+
# inside the verb's own mapping as well as beside it. Silently passing them
|
|
194
|
+
# through as verb arguments instead fails at runtime with a confusing
|
|
195
|
+
# signature error, and — worse — quietly drops the retry the author asked
|
|
196
|
+
# for. No verb takes an argument by any of these names.
|
|
197
|
+
args = dict(args)
|
|
198
|
+
for key in sorted(STEP_META & set(args)):
|
|
199
|
+
meta.setdefault(key, args.pop(key))
|
|
200
|
+
|
|
201
|
+
return Step(
|
|
202
|
+
verb=verb,
|
|
203
|
+
args=args,
|
|
204
|
+
retry=int(meta.get("retry", 0)),
|
|
205
|
+
optional=bool(meta.get("optional", False)),
|
|
206
|
+
settle_s=float(meta.get("settle_s", 0.0)),
|
|
207
|
+
label=str(meta.get("label", "")),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse(name: str, raw: Any) -> Recipe:
|
|
212
|
+
"""Turn one entry of the config's `recipes:` mapping into a Recipe."""
|
|
213
|
+
if not NAME_RE.match(name):
|
|
214
|
+
raise RecipeError(f"recipe name {name!r} must match {NAME_RE.pattern} to be usable as a tool name")
|
|
215
|
+
if isinstance(raw, list): # shorthand: just steps
|
|
216
|
+
raw = {"steps": raw}
|
|
217
|
+
if not isinstance(raw, dict):
|
|
218
|
+
raise RecipeError(f"recipe {name!r} must be a mapping, got {type(raw).__name__}")
|
|
219
|
+
|
|
220
|
+
unknown = set(raw) - {"description", "params", "steps", "on_failure"}
|
|
221
|
+
if unknown:
|
|
222
|
+
raise RecipeError(f"recipe {name!r}: unknown key(s) {sorted(unknown)}")
|
|
223
|
+
|
|
224
|
+
steps_raw = raw.get("steps")
|
|
225
|
+
if not steps_raw:
|
|
226
|
+
raise RecipeError(f"recipe {name!r} has no steps")
|
|
227
|
+
if not isinstance(steps_raw, list):
|
|
228
|
+
raise RecipeError(f"recipe {name!r}: `steps` must be a list")
|
|
229
|
+
|
|
230
|
+
on_failure = str(raw.get("on_failure", "stop"))
|
|
231
|
+
if on_failure not in {"stop", "continue"}:
|
|
232
|
+
raise RecipeError(f"recipe {name!r}: on_failure must be 'stop' or 'continue', got {on_failure!r}")
|
|
233
|
+
|
|
234
|
+
return Recipe(
|
|
235
|
+
name=name,
|
|
236
|
+
description=str(raw.get("description", "") or f"Run the {name} flow"),
|
|
237
|
+
params=_parse_params(name, raw.get("params")),
|
|
238
|
+
steps=[_parse_step(name, i, s) for i, s in enumerate(steps_raw)],
|
|
239
|
+
on_failure=on_failure,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def load_all(cfg: Config) -> dict[str, Recipe]:
|
|
244
|
+
"""Parse every recipe in the config. One bad recipe does not hide the others."""
|
|
245
|
+
out: dict[str, Recipe] = {}
|
|
246
|
+
for name, raw in (cfg.recipes or {}).items():
|
|
247
|
+
try:
|
|
248
|
+
out[str(name)] = parse(str(name), raw)
|
|
249
|
+
except RecipeError as e:
|
|
250
|
+
log("recipes", f"SKIPPED: {e}")
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── interpolation ─────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def interpolate(value: Any, params: dict[str, Any]) -> Any:
|
|
258
|
+
"""Substitute `{{param}}` throughout a step's arguments.
|
|
259
|
+
|
|
260
|
+
A value that is *entirely* one placeholder keeps the parameter's own type, so
|
|
261
|
+
`timeout_s: "{{wait}}"` with `wait=30` stays the integer 30 rather than "30".
|
|
262
|
+
"""
|
|
263
|
+
if isinstance(value, str):
|
|
264
|
+
whole = WHOLE_PLACEHOLDER_RE.match(value.strip())
|
|
265
|
+
if whole:
|
|
266
|
+
return _lookup(whole.group(1), params)
|
|
267
|
+
return PLACEHOLDER_RE.sub(lambda m: str(_lookup(m.group(1), params)), value)
|
|
268
|
+
if isinstance(value, dict):
|
|
269
|
+
return {k: interpolate(v, params) for k, v in value.items()}
|
|
270
|
+
if isinstance(value, list):
|
|
271
|
+
return [interpolate(v, params) for v in value]
|
|
272
|
+
return value
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _lookup(name: str, params: dict[str, Any]) -> Any:
|
|
276
|
+
if name not in params:
|
|
277
|
+
raise StepFailed(
|
|
278
|
+
f"{{{{{name}}}}} is not a parameter of this recipe. Available: {sorted(params) or '(none)'}"
|
|
279
|
+
)
|
|
280
|
+
return params[name]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ── execution ─────────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class Context:
|
|
287
|
+
"""Everything a step needs to touch the device."""
|
|
288
|
+
|
|
289
|
+
def __init__(self, session: Session, cfg: Config, runs: Runs) -> None:
|
|
290
|
+
self.session = session
|
|
291
|
+
self.cfg = cfg
|
|
292
|
+
self.runs = runs
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _expect_result(result: dict[str, Any]) -> dict[str, Any]:
|
|
296
|
+
"""Turn a failed assertion into a step failure; pass a successful one through."""
|
|
297
|
+
if result.get("ok") is False:
|
|
298
|
+
raise StepFailed(str(result.get("error", "assertion failed")), result)
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _step_sleep(ctx: Context, seconds: float = 1.0) -> dict[str, Any]:
|
|
303
|
+
time.sleep(float(seconds))
|
|
304
|
+
return {"slept_s": float(seconds)}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _step_screenshot(ctx: Context, name: str | None = None) -> dict[str, Any]:
|
|
308
|
+
stem = name or f"step-{int(time.time() * 1000) % 100000}"
|
|
309
|
+
path = ctx.runs.artifact_dir("screenshots") / f"{stem}.png"
|
|
310
|
+
actions.screenshot(ctx.session, path)
|
|
311
|
+
return {"screenshot": str(path)}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# verb → (callable taking (ctx, **args))
|
|
315
|
+
VERBS: dict[str, Callable[..., dict[str, Any]]] = {
|
|
316
|
+
# UI
|
|
317
|
+
"tap": lambda ctx, **kw: actions.tap(ctx.session, **_rename_id(kw)),
|
|
318
|
+
"tap_xy": lambda ctx, **kw: actions.tap_xy(ctx.session, **kw),
|
|
319
|
+
"long_press": lambda ctx, **kw: actions.long_press(ctx.session, **_rename_id(kw)),
|
|
320
|
+
"type": lambda ctx, **kw: actions.type_text(ctx.session, **_rename_id(kw)),
|
|
321
|
+
"swipe": lambda ctx, **kw: actions.swipe(ctx.session, **kw),
|
|
322
|
+
"scroll_to": lambda ctx, **kw: actions.scroll_to(ctx.session, **_rename_id(kw)),
|
|
323
|
+
"press": lambda ctx, **kw: actions.press_key(ctx.session, **kw),
|
|
324
|
+
"screenshot": _step_screenshot,
|
|
325
|
+
# app lifecycle
|
|
326
|
+
"build": lambda ctx, **kw: actions.build_app(ctx.cfg, **kw),
|
|
327
|
+
"install": lambda ctx, **kw: actions.install_app(ctx.session, ctx.cfg, **kw),
|
|
328
|
+
"uninstall": lambda ctx, **kw: actions.uninstall_app(ctx.session, ctx.cfg, **kw),
|
|
329
|
+
"launch": lambda ctx, **kw: actions.launch_app(ctx.session, ctx.cfg, **kw),
|
|
330
|
+
"force_stop": lambda ctx, **kw: actions.force_stop(ctx.session, ctx.cfg, **kw),
|
|
331
|
+
"clear_data": lambda ctx, **kw: actions.clear_app_data(ctx.session, ctx.cfg, **kw),
|
|
332
|
+
# emulator
|
|
333
|
+
"snapshot_save": lambda ctx, **kw: emulator.snapshot_save(ctx.session.serial, **kw),
|
|
334
|
+
"snapshot_load": lambda ctx, **kw: _after_snapshot(ctx, emulator.snapshot_load(ctx.session.serial, **kw)),
|
|
335
|
+
# assertions
|
|
336
|
+
"expect_visible": lambda ctx, **kw: _expect_result(expect.visible(ctx.session, **_rename_id(kw))),
|
|
337
|
+
"expect_gone": lambda ctx, **kw: _expect_result(expect.gone(ctx.session, **_rename_id(kw))),
|
|
338
|
+
"expect_log": lambda ctx, **kw: _expect_result(expect.log_matches(ctx.session, ctx.cfg, **kw)),
|
|
339
|
+
"expect_no_crash": lambda ctx, **kw: _expect_result(expect.no_crash(ctx.session, ctx.cfg, **kw)),
|
|
340
|
+
# misc
|
|
341
|
+
"sleep": _step_sleep,
|
|
342
|
+
"shell": lambda ctx, **kw: actions.shell(ctx.session, **kw),
|
|
343
|
+
"logcat_clear": lambda ctx: (adb.logcat_clear(ctx.session.serial), {})[1],
|
|
344
|
+
"run": lambda ctx, **kw: {}, # replaced by Runner; declared here so parsing accepts it
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _rename_id(kw: dict[str, Any]) -> dict[str, Any]:
|
|
349
|
+
"""`id:` reads better in YAML than `rid:`, which is what the action layer takes."""
|
|
350
|
+
if "id" in kw:
|
|
351
|
+
kw = dict(kw)
|
|
352
|
+
kw["rid"] = kw.pop("id")
|
|
353
|
+
return kw
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _after_snapshot(ctx: Context, result: dict[str, Any]) -> dict[str, Any]:
|
|
357
|
+
ctx.session.invalidate()
|
|
358
|
+
return result
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class Runner:
|
|
362
|
+
def __init__(self, ctx: Context, registry: dict[str, Recipe]) -> None:
|
|
363
|
+
self.ctx = ctx
|
|
364
|
+
self.registry = registry
|
|
365
|
+
|
|
366
|
+
def run(self, recipe: Recipe, params: dict[str, Any], depth: int = 0) -> dict[str, Any]:
|
|
367
|
+
if depth > MAX_RECIPE_DEPTH:
|
|
368
|
+
raise StepFailed(f"recipe nesting deeper than {MAX_RECIPE_DEPTH}; is {recipe.name!r} recursive?")
|
|
369
|
+
|
|
370
|
+
bound = self._bind(recipe, params)
|
|
371
|
+
redacted = {
|
|
372
|
+
p.name: ("***" if p.secret else bound[p.name]) for p in recipe.params if p.name in bound
|
|
373
|
+
}
|
|
374
|
+
log("recipes", f"running {recipe.name}({redacted})")
|
|
375
|
+
|
|
376
|
+
started = time.monotonic()
|
|
377
|
+
records: list[dict[str, Any]] = []
|
|
378
|
+
failure: dict[str, Any] | None = None
|
|
379
|
+
|
|
380
|
+
for index, step in enumerate(recipe.steps):
|
|
381
|
+
record = self._run_step(recipe, index, step, bound, depth)
|
|
382
|
+
records.append(record)
|
|
383
|
+
if record["status"] == "failed":
|
|
384
|
+
failure = record
|
|
385
|
+
if recipe.on_failure == "stop":
|
|
386
|
+
break
|
|
387
|
+
|
|
388
|
+
ok = failure is None
|
|
389
|
+
result: dict[str, Any] = {
|
|
390
|
+
"ok": ok,
|
|
391
|
+
"recipe": recipe.name,
|
|
392
|
+
"params": redacted,
|
|
393
|
+
"steps": records,
|
|
394
|
+
"duration_s": round(time.monotonic() - started, 2),
|
|
395
|
+
}
|
|
396
|
+
if failure is not None:
|
|
397
|
+
result["error"] = f"step {failure['index']} (`{failure['step']}`) failed: {failure['error']}"
|
|
398
|
+
result["failed_step"] = failure
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
def _bind(self, recipe: Recipe, params: dict[str, Any]) -> dict[str, Any]:
|
|
404
|
+
known = recipe.param_map
|
|
405
|
+
unknown = set(params) - set(known)
|
|
406
|
+
if unknown:
|
|
407
|
+
raise StepFailed(
|
|
408
|
+
f"recipe {recipe.name!r} got unknown parameter(s) {sorted(unknown)}; "
|
|
409
|
+
f"it takes {sorted(known) or '(none)'}"
|
|
410
|
+
)
|
|
411
|
+
bound: dict[str, Any] = {}
|
|
412
|
+
for param in recipe.params:
|
|
413
|
+
if param.name in params and params[param.name] is not None:
|
|
414
|
+
bound[param.name] = params[param.name]
|
|
415
|
+
elif param.required:
|
|
416
|
+
raise StepFailed(f"recipe {recipe.name!r} requires the {param.name!r} parameter")
|
|
417
|
+
else:
|
|
418
|
+
bound[param.name] = param.default
|
|
419
|
+
return bound
|
|
420
|
+
|
|
421
|
+
def _run_step(
|
|
422
|
+
self, recipe: Recipe, index: int, step: Step, params: dict[str, Any], depth: int
|
|
423
|
+
) -> dict[str, Any]:
|
|
424
|
+
label = f"{recipe.name}.{step.name}"
|
|
425
|
+
started = time.monotonic()
|
|
426
|
+
attempts = step.retry + 1
|
|
427
|
+
last_error: Exception | None = None
|
|
428
|
+
detail: dict[str, Any] = {}
|
|
429
|
+
|
|
430
|
+
for attempt in range(attempts):
|
|
431
|
+
try:
|
|
432
|
+
args = interpolate(step.args, params)
|
|
433
|
+
payload = self._dispatch(step.verb, args, depth) or {}
|
|
434
|
+
if step.settle_s:
|
|
435
|
+
time.sleep(step.settle_s)
|
|
436
|
+
duration = round(time.monotonic() - started, 3)
|
|
437
|
+
self.ctx.runs.record_event(label, "ok", duration, summarize(payload))
|
|
438
|
+
return {
|
|
439
|
+
"index": index + 1,
|
|
440
|
+
"step": step.name,
|
|
441
|
+
"status": "ok",
|
|
442
|
+
"duration_s": duration,
|
|
443
|
+
**summarize(payload),
|
|
444
|
+
}
|
|
445
|
+
except Exception as e:
|
|
446
|
+
last_error = e
|
|
447
|
+
detail = getattr(e, "detail", {}) or {}
|
|
448
|
+
if attempt < attempts - 1:
|
|
449
|
+
log("recipes", f"{label} failed ({e}); retry {attempt + 1}/{step.retry}")
|
|
450
|
+
time.sleep(0.5 * (attempt + 1))
|
|
451
|
+
# Re-read the screen before trying again. A retry against the
|
|
452
|
+
# same cached snapshot can only fail the same way, and the
|
|
453
|
+
# usual reason a step needs retrying is that the UI had not
|
|
454
|
+
# finished settling when we looked.
|
|
455
|
+
self.ctx.session.invalidate()
|
|
456
|
+
|
|
457
|
+
duration = round(time.monotonic() - started, 3)
|
|
458
|
+
message = f"{type(last_error).__name__}: {last_error}"
|
|
459
|
+
artifacts = self.ctx.runs.capture(self.ctx.session, f"{recipe.name}-{index + 1}-{step.verb}")
|
|
460
|
+
status = "skipped" if step.optional else "failed"
|
|
461
|
+
record = {
|
|
462
|
+
"index": index + 1,
|
|
463
|
+
"step": step.name,
|
|
464
|
+
"status": status,
|
|
465
|
+
"duration_s": duration,
|
|
466
|
+
"error": message,
|
|
467
|
+
**artifacts,
|
|
468
|
+
}
|
|
469
|
+
if detail.get("screen"):
|
|
470
|
+
record["screen"] = detail["screen"]
|
|
471
|
+
self.ctx.runs.record_event(label, status, duration, {"error": message, **artifacts})
|
|
472
|
+
return record
|
|
473
|
+
|
|
474
|
+
def _dispatch(self, verb: str, args: dict[str, Any], depth: int) -> dict[str, Any]:
|
|
475
|
+
if verb == "run":
|
|
476
|
+
nested = args.get("recipe")
|
|
477
|
+
if nested not in self.registry:
|
|
478
|
+
raise StepFailed(f"no recipe named {nested!r}. Known: {sorted(self.registry)}")
|
|
479
|
+
child_params = {k: v for k, v in args.items() if k != "recipe"}
|
|
480
|
+
result = self.run(self.registry[nested], child_params, depth + 1)
|
|
481
|
+
if not result["ok"]:
|
|
482
|
+
raise StepFailed(result.get("error", f"nested recipe {nested!r} failed"), result)
|
|
483
|
+
return {"recipe": nested, "steps": len(result["steps"])}
|
|
484
|
+
try:
|
|
485
|
+
return VERBS[verb](self.ctx, **args)
|
|
486
|
+
except TypeError as e:
|
|
487
|
+
# A wrong argument name is a config mistake, so say so in those terms
|
|
488
|
+
# rather than leaking a Python signature error at the agent.
|
|
489
|
+
raise StepFailed(f"`{verb}` does not accept those arguments ({e})") from e
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def summarize(payload: dict[str, Any], limit: int = 300) -> dict[str, Any]:
|
|
493
|
+
"""Keep timeline entries readable — drop the bulky fields assertions return."""
|
|
494
|
+
out: dict[str, Any] = {}
|
|
495
|
+
for key, value in payload.items():
|
|
496
|
+
if key in {"screen", "excerpt", "tail", "matches", "steps"}:
|
|
497
|
+
continue
|
|
498
|
+
if isinstance(value, str) and len(value) > limit:
|
|
499
|
+
value = value[:limit] + "…"
|
|
500
|
+
out[key] = value
|
|
501
|
+
return out
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# ── MCP tool generation ───────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def build_tool(recipe: Recipe, runner_factory: Callable[[], Runner]) -> Callable[..., dict[str, Any]]:
|
|
508
|
+
"""Wrap a recipe in a function whose signature is its parameter list.
|
|
509
|
+
|
|
510
|
+
FastMCP derives a tool's JSON schema from `inspect.signature`, so a synthetic
|
|
511
|
+
signature is what makes `login(email, password)` show up as a typed tool
|
|
512
|
+
instead of an opaque `run_recipe(name, params)` call.
|
|
513
|
+
"""
|
|
514
|
+
def tool(**kwargs: Any) -> dict[str, Any]:
|
|
515
|
+
try:
|
|
516
|
+
return runner_factory().run(recipe, kwargs)
|
|
517
|
+
except StepFailed as e:
|
|
518
|
+
return {"ok": False, "recipe": recipe.name, "error": str(e), **(e.detail or {})}
|
|
519
|
+
except Exception as e:
|
|
520
|
+
return {"ok": False, "recipe": recipe.name, "error": f"{type(e).__name__}: {e}"}
|
|
521
|
+
|
|
522
|
+
ordered = sorted(recipe.params, key=lambda p: not p.required)
|
|
523
|
+
parameters = [
|
|
524
|
+
inspect.Parameter(
|
|
525
|
+
p.name,
|
|
526
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
527
|
+
default=inspect.Parameter.empty if p.required else p.default,
|
|
528
|
+
annotation=p.py_type if p.required else (p.py_type | None),
|
|
529
|
+
)
|
|
530
|
+
for p in ordered
|
|
531
|
+
]
|
|
532
|
+
tool.__signature__ = inspect.Signature(parameters, return_annotation=dict[str, Any]) # type: ignore[attr-defined]
|
|
533
|
+
tool.__name__ = recipe.name
|
|
534
|
+
tool.__doc__ = _docstring(recipe)
|
|
535
|
+
return tool
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _docstring(recipe: Recipe) -> str:
|
|
539
|
+
lines = [recipe.description.strip() or f"Run the {recipe.name} flow."]
|
|
540
|
+
if recipe.params:
|
|
541
|
+
lines.append("")
|
|
542
|
+
for p in recipe.params:
|
|
543
|
+
suffix = "" if p.required else f" (default: {p.default!r})"
|
|
544
|
+
lines.append(f" {p.name}: {p.description or p.type}{suffix}")
|
|
545
|
+
lines.append("")
|
|
546
|
+
lines.append(f"Steps: {' → '.join(s.name for s in recipe.steps)}")
|
|
547
|
+
return "\n".join(lines)
|
android_driver/record.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Screen recording via `screenrecord`.
|
|
2
|
+
|
|
3
|
+
The device-side process is started detached and stopped with SIGINT rather than
|
|
4
|
+
being killed: `screenrecord` only writes the MP4 moov atom when it shuts down
|
|
5
|
+
cleanly, and a SIGKILLed recording leaves a file no player will open. That is
|
|
6
|
+
why this goes through a device-side PID instead of the simpler
|
|
7
|
+
`Popen(["adb", "shell", "screenrecord", ...])` — a local kill does not reliably
|
|
8
|
+
reach the remote process at all.
|
|
9
|
+
|
|
10
|
+
`screenrecord` caps a single clip at 3 minutes and does not capture on some
|
|
11
|
+
emulator GPU configurations; both show up as an empty or missing file, which
|
|
12
|
+
`stop()` reports rather than hiding.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from . import adb
|
|
22
|
+
from .log import log
|
|
23
|
+
|
|
24
|
+
REMOTE_DIR = "/sdcard"
|
|
25
|
+
MAX_TIME_LIMIT_S = 180
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RecordError(RuntimeError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Recorder:
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.active: dict[str, Any] | None = None
|
|
35
|
+
|
|
36
|
+
def start(
|
|
37
|
+
self,
|
|
38
|
+
serial: str,
|
|
39
|
+
*,
|
|
40
|
+
name: str | None = None,
|
|
41
|
+
bit_rate_mbps: float = 4.0,
|
|
42
|
+
size: str | None = None,
|
|
43
|
+
time_limit_s: int = MAX_TIME_LIMIT_S,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
if self.active is not None:
|
|
46
|
+
raise RecordError(
|
|
47
|
+
f"already recording to {self.active['remote']}; call `record_stop` first"
|
|
48
|
+
)
|
|
49
|
+
if time_limit_s > MAX_TIME_LIMIT_S:
|
|
50
|
+
log("record", f"screenrecord caps clips at {MAX_TIME_LIMIT_S}s; clamping {time_limit_s}s")
|
|
51
|
+
time_limit_s = MAX_TIME_LIMIT_S
|
|
52
|
+
|
|
53
|
+
stem = name or f"record-{int(time.time())}"
|
|
54
|
+
remote = f"{REMOTE_DIR}/android-driver-{stem}.mp4"
|
|
55
|
+
opts = f"--bit-rate {int(bit_rate_mbps * 1_000_000)} --time-limit {time_limit_s}"
|
|
56
|
+
if size:
|
|
57
|
+
opts += f" --size {size}"
|
|
58
|
+
# nohup + & keeps it alive after this adb shell exits; `echo $!` hands
|
|
59
|
+
# back the device-side PID we need in order to SIGINT it later.
|
|
60
|
+
result = adb.shell_result(
|
|
61
|
+
serial, f"nohup screenrecord {opts} {remote} >/dev/null 2>&1 & echo $!", timeout=30
|
|
62
|
+
)
|
|
63
|
+
pid = result["stdout"].strip().split()[-1] if result["stdout"].strip() else ""
|
|
64
|
+
if not pid.isdigit():
|
|
65
|
+
raise RecordError(
|
|
66
|
+
f"could not start screenrecord on {serial}: {result['stdout']} {result['stderr']}".strip()
|
|
67
|
+
)
|
|
68
|
+
self.active = {"serial": serial, "remote": remote, "pid": int(pid), "started": time.monotonic()}
|
|
69
|
+
log("record", f"recording {serial} → {remote} (pid {pid})")
|
|
70
|
+
return {"remote_path": remote, "pid": int(pid), "time_limit_s": time_limit_s}
|
|
71
|
+
|
|
72
|
+
def stop(self, dest: Path) -> dict[str, Any]:
|
|
73
|
+
if self.active is None:
|
|
74
|
+
raise RecordError("not recording — call `record_start` first")
|
|
75
|
+
state, self.active = self.active, None
|
|
76
|
+
serial, remote, pid = state["serial"], state["remote"], state["pid"]
|
|
77
|
+
|
|
78
|
+
adb.shell_result(serial, f"kill -2 {pid}", timeout=30)
|
|
79
|
+
self._await_finalized(serial, remote)
|
|
80
|
+
|
|
81
|
+
dest = Path(dest)
|
|
82
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
pull = adb.run(serial, "pull", remote, str(dest), check=False, timeout=180)
|
|
84
|
+
adb.shell_result(serial, f"rm -f {remote}", timeout=30)
|
|
85
|
+
|
|
86
|
+
if not dest.is_file() or dest.stat().st_size == 0:
|
|
87
|
+
raise RecordError(
|
|
88
|
+
f"screenrecord produced no usable file (adb pull said: {pull.strip()}). "
|
|
89
|
+
"Some emulator GPU modes cannot capture; try `-gpu swiftshader_indirect`."
|
|
90
|
+
)
|
|
91
|
+
return {
|
|
92
|
+
"path": str(dest),
|
|
93
|
+
"bytes": dest.stat().st_size,
|
|
94
|
+
"seconds": round(time.monotonic() - state["started"], 1),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _await_finalized(serial: str, remote: str, timeout_s: float = 15.0) -> None:
|
|
99
|
+
"""Wait for the file size to stop growing — the moov atom is written last."""
|
|
100
|
+
deadline = time.monotonic() + timeout_s
|
|
101
|
+
last = -1
|
|
102
|
+
while time.monotonic() < deadline:
|
|
103
|
+
time.sleep(0.5)
|
|
104
|
+
out = adb.shell_result(serial, f"stat -c %s {remote} 2>/dev/null || echo 0")["stdout"]
|
|
105
|
+
try:
|
|
106
|
+
size = int(out.strip().splitlines()[-1])
|
|
107
|
+
except (ValueError, IndexError):
|
|
108
|
+
size = 0
|
|
109
|
+
if size and size == last:
|
|
110
|
+
return
|
|
111
|
+
last = size
|
|
112
|
+
log("record", f"{remote} was still changing after {timeout_s}s; pulling anyway")
|